• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zostera / django-bootstrap4 / 14969570320

12 May 2025 10:11AM UTC coverage: 90.734%. Remained the same
14969570320

Pull #818

github

web-flow
Merge 4584b0cfb into f9a52afbd
Pull Request #818: Bump dependabot/fetch-metadata from 2.2.0 to 2.4.0

233 of 302 branches covered (77.15%)

Branch coverage included in aggregate %.

1275 of 1360 relevant lines covered (93.75%)

10.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.74
/src/bootstrap4/renderers.py
1
from bs4 import BeautifulSoup
11✔
2
from django.forms import (
11✔
3
    BaseForm,
4
    BaseFormSet,
5
    BoundField,
6
    CheckboxInput,
7
    CheckboxSelectMultiple,
8
    DateInput,
9
    EmailInput,
10
    FileInput,
11
    MultiWidget,
12
    NumberInput,
13
    PasswordInput,
14
    RadioSelect,
15
    Select,
16
    SelectDateWidget,
17
    TextInput,
18
    URLInput,
19
)
20
from django.utils.html import conditional_escape, escape, strip_tags
11✔
21
from django.utils.safestring import mark_safe
11✔
22

23
from .bootstrap import get_bootstrap_setting
11✔
24
from .exceptions import BootstrapError
11✔
25
from .forms import (
11✔
26
    FORM_GROUP_CLASS,
27
    is_widget_with_placeholder,
28
    render_field,
29
    render_form,
30
    render_form_group,
31
    render_label,
32
)
33
from .text import text_value
11✔
34
from .utils import add_css_class, render_template_file
11✔
35

36
try:
11✔
37
    # If Django is set up without a database, importing this widget gives RuntimeError
38
    from django.contrib.auth.forms import ReadOnlyPasswordHashWidget
11✔
39
except RuntimeError:
×
40
    ReadOnlyPasswordHashWidget = None
×
41

42

43
class BaseRenderer:
11✔
44
    """A content renderer."""
45

46
    def __init__(self, *args, **kwargs):
11✔
47
        self.layout = kwargs.get("layout", "")
11✔
48
        self.form_group_class = kwargs.get("form_group_class", FORM_GROUP_CLASS)
11✔
49
        self.field_class = kwargs.get("field_class", "")
11✔
50
        self.label_class = kwargs.get("label_class", "")
11✔
51
        self.show_help = kwargs.get("show_help", True)
11✔
52
        self.show_label = kwargs.get("show_label", True)
11✔
53
        self.exclude = kwargs.get("exclude", "")
11✔
54

55
        self.set_placeholder = kwargs.get("set_placeholder", True)
11✔
56
        self.size = self.parse_size(kwargs.get("size", ""))
11✔
57
        self.horizontal_label_class = kwargs.get(
11✔
58
            "horizontal_label_class", get_bootstrap_setting("horizontal_label_class")
59
        )
60
        self.horizontal_field_class = kwargs.get(
11✔
61
            "horizontal_field_class", get_bootstrap_setting("horizontal_field_class")
62
        )
63

64
    def parse_size(self, size):
11✔
65
        size = text_value(size).lower().strip()
11✔
66
        if size in ("sm", "small"):
11✔
67
            return "small"
11✔
68
        if size in ("lg", "large"):
11✔
69
            return "large"
11✔
70
        if size in ("md", "medium", ""):
11!
71
            return "medium"
11✔
72
        raise BootstrapError(f'Invalid value "{size}" for parameter "size" (expected "sm", "md", "lg" or "").')
×
73

74
    def get_size_class(self, prefix="form-control"):
11✔
75
        if self.size == "small":
11✔
76
            return prefix + "-sm"
11✔
77
        if self.size == "large":
11✔
78
            return prefix + "-lg"
11✔
79
        return ""
11✔
80

81
    def _render(self):
11✔
82
        return ""
×
83

84
    def render(self):
11✔
85
        return mark_safe(self._render())
11✔
86

87

88
class FormsetRenderer(BaseRenderer):
11✔
89
    """Default formset renderer."""
90

91
    def __init__(self, formset, *args, **kwargs):
11✔
92
        if not isinstance(formset, BaseFormSet):
11✔
93
            raise BootstrapError('Parameter "formset" should contain a valid Django Formset.')
11✔
94
        self.formset = formset
11✔
95
        super().__init__(*args, **kwargs)
11✔
96

97
    def render_management_form(self):
11✔
98
        return text_value(self.formset.management_form)
11✔
99

100
    def render_form(self, form, **kwargs):
11✔
101
        return render_form(form, **kwargs)
11✔
102

103
    def render_forms(self):
11✔
104
        rendered_forms = []
11✔
105
        for form in self.formset.forms:
11✔
106
            rendered_forms.append(
11✔
107
                self.render_form(
108
                    form,
109
                    layout=self.layout,
110
                    form_group_class=self.form_group_class,
111
                    field_class=self.field_class,
112
                    label_class=self.label_class,
113
                    show_label=self.show_label,
114
                    show_help=self.show_help,
115
                    exclude=self.exclude,
116
                    set_placeholder=self.set_placeholder,
117
                    size=self.size,
118
                    horizontal_label_class=self.horizontal_label_class,
119
                    horizontal_field_class=self.horizontal_field_class,
120
                )
121
            )
122
        return "\n".join(rendered_forms)
11✔
123

124
    def get_formset_errors(self):
11✔
125
        return self.formset.non_form_errors()
11✔
126

127
    def render_errors(self):
11✔
128
        formset_errors = self.get_formset_errors()
11✔
129
        if formset_errors:
11!
130
            return render_template_file(
×
131
                "bootstrap4/form_errors.html",
132
                context={"errors": formset_errors, "form": self.formset, "layout": self.layout},
133
            )
134
        return ""
11✔
135

136
    def _render(self):
11✔
137
        return "".join([self.render_errors(), self.render_management_form(), self.render_forms()])
11✔
138

139

140
class FormRenderer(BaseRenderer):
11✔
141
    """Default form renderer."""
142

143
    def __init__(self, form, *args, **kwargs):
11✔
144
        if not isinstance(form, BaseForm):
11✔
145
            raise BootstrapError('Parameter "form" should contain a valid Django Form.')
11✔
146
        self.form = form
11✔
147
        super().__init__(*args, **kwargs)
11✔
148
        self.error_css_class = kwargs.get("error_css_class", None)
11✔
149
        self.required_css_class = kwargs.get("required_css_class", None)
11✔
150
        self.bound_css_class = kwargs.get("bound_css_class", None)
11✔
151
        self.alert_error_type = kwargs.get("alert_error_type", "non_fields")
11✔
152
        self.form_check_class = kwargs.get("form_check_class", "form-check")
11✔
153

154
    def render_fields(self):
11✔
155
        rendered_fields = []
11✔
156
        for field in self.form:
11✔
157
            rendered_fields.append(
11✔
158
                render_field(
159
                    field,
160
                    layout=self.layout,
161
                    form_group_class=self.form_group_class,
162
                    field_class=self.field_class,
163
                    label_class=self.label_class,
164
                    form_check_class=self.form_check_class,
165
                    show_label=self.show_label,
166
                    show_help=self.show_help,
167
                    exclude=self.exclude,
168
                    set_placeholder=self.set_placeholder,
169
                    size=self.size,
170
                    horizontal_label_class=self.horizontal_label_class,
171
                    horizontal_field_class=self.horizontal_field_class,
172
                    error_css_class=self.error_css_class,
173
                    required_css_class=self.required_css_class,
174
                    bound_css_class=self.bound_css_class,
175
                )
176
            )
177
        return "\n".join(rendered_fields)
11✔
178

179
    def get_fields_errors(self):
11✔
180
        form_errors = []
11✔
181
        for field in self.form:
11✔
182
            if not field.is_hidden and field.errors:
11✔
183
                form_errors += field.errors
11✔
184
        return form_errors
11✔
185

186
    def render_errors(self, type="all"):
11✔
187
        form_errors = None
11✔
188
        if type == "all":
11✔
189
            form_errors = self.get_fields_errors() + self.form.non_field_errors()
11✔
190
        elif type == "fields":
11✔
191
            form_errors = self.get_fields_errors()
11✔
192
        elif type == "non_fields":
11✔
193
            form_errors = self.form.non_field_errors()
11✔
194

195
        if form_errors:
11✔
196
            return render_template_file(
11✔
197
                "bootstrap4/form_errors.html",
198
                context={"errors": form_errors, "form": self.form, "layout": self.layout, "type": type},
199
            )
200

201
        return ""
11✔
202

203
    def _render(self):
11✔
204
        return self.render_errors(self.alert_error_type) + self.render_fields()
11✔
205

206

207
class FieldRenderer(BaseRenderer):
11✔
208
    """Default field renderer."""
209

210
    # These widgets will not be wrapped in a form-control class
211
    WIDGETS_NO_FORM_CONTROL = (CheckboxInput, RadioSelect, CheckboxSelectMultiple, FileInput)
11✔
212

213
    def __init__(self, field, *args, **kwargs):
11✔
214
        if not isinstance(field, BoundField):
11✔
215
            raise BootstrapError('Parameter "field" should contain a valid Django BoundField.')
11✔
216
        self.field = field
11✔
217
        super().__init__(*args, **kwargs)
11✔
218

219
        self.widget = field.field.widget
11✔
220
        self.is_multi_widget = isinstance(field.field.widget, MultiWidget)
11✔
221
        self.initial_attrs = self.widget.attrs.copy()
11✔
222
        self.field_help = text_value(mark_safe(field.help_text)) if self.show_help and field.help_text else ""
11✔
223
        self.field_errors = [conditional_escape(text_value(error)) for error in field.errors]
11✔
224
        self.form_check_class = kwargs.get("form_check_class", "form-check")
11✔
225

226
        if "placeholder" in kwargs:
11!
227
            # Find the placeholder in kwargs, even if it's empty
228
            self.placeholder = kwargs["placeholder"]
×
229
        elif get_bootstrap_setting("set_placeholder"):
11!
230
            # If not found, see if we set the label
231
            self.placeholder = field.label
11✔
232
        else:
233
            # Or just set it to empty
234
            self.placeholder = ""
×
235
        if self.placeholder:
11!
236
            self.placeholder = text_value(self.placeholder)
11✔
237

238
        self.addon_before = kwargs.get("addon_before", self.widget.attrs.pop("addon_before", ""))
11✔
239
        self.addon_after = kwargs.get("addon_after", self.widget.attrs.pop("addon_after", ""))
11✔
240
        self.addon_before_class = kwargs.get(
11✔
241
            "addon_before_class", self.widget.attrs.pop("addon_before_class", "input-group-text")
242
        )
243
        self.addon_after_class = kwargs.get(
11✔
244
            "addon_after_class", self.widget.attrs.pop("addon_after_class", "input-group-text")
245
        )
246

247
        # These are set in Django or in the global BOOTSTRAP4 settings, and
248
        # they can be overwritten in the template
249
        error_css_class = kwargs.get("error_css_class", None)
11✔
250
        required_css_class = kwargs.get("required_css_class", None)
11✔
251
        bound_css_class = kwargs.get("bound_css_class", None)
11✔
252
        if error_css_class is not None:
11✔
253
            self.error_css_class = error_css_class
11✔
254
        else:
255
            self.error_css_class = getattr(field.form, "error_css_class", get_bootstrap_setting("error_css_class"))
11✔
256
        if required_css_class is not None:
11✔
257
            self.required_css_class = required_css_class
11✔
258
        else:
259
            self.required_css_class = getattr(
11✔
260
                field.form, "required_css_class", get_bootstrap_setting("required_css_class")
261
            )
262
        if bound_css_class is not None:
11✔
263
            self.success_css_class = bound_css_class
11✔
264
        else:
265
            self.success_css_class = getattr(field.form, "bound_css_class", get_bootstrap_setting("success_css_class"))
11✔
266

267
        # If the form is marked as form.empty_permitted, do not set required class
268
        if self.field.form.empty_permitted:
11✔
269
            self.required_css_class = ""
11✔
270

271
    def restore_widget_attrs(self):
11✔
272
        self.widget.attrs = self.initial_attrs.copy()
11✔
273

274
    def add_class_attrs(self, widget=None):
11✔
275
        if widget is None:
11!
276
            widget = self.widget
×
277
        classes = widget.attrs.get("class", "")
11✔
278
        if ReadOnlyPasswordHashWidget is not None and isinstance(widget, ReadOnlyPasswordHashWidget):
11!
279
            # Render this is a static control
280
            classes = add_css_class(classes, "form-control-static", prepend=True)
×
281
        elif not isinstance(widget, self.WIDGETS_NO_FORM_CONTROL):
11✔
282
            classes = add_css_class(classes, "form-control", prepend=True)
11✔
283
            # For these widget types, add the size class here
284
            classes = add_css_class(classes, self.get_size_class())
11✔
285
        elif isinstance(widget, CheckboxInput):
11✔
286
            classes = add_css_class(classes, "form-check-input", prepend=True)
11✔
287
        elif isinstance(widget, FileInput):
11!
288
            classes = add_css_class(classes, "form-control-file", prepend=True)
×
289

290
        if self.field.errors:
11✔
291
            if self.error_css_class:
11✔
292
                classes = add_css_class(classes, self.error_css_class)
11✔
293
        else:
294
            if self.field.form.is_bound:
11✔
295
                classes = add_css_class(classes, self.success_css_class)
11✔
296

297
        widget.attrs["class"] = classes
11✔
298

299
    def add_placeholder_attrs(self, widget=None):
11✔
300
        if widget is None:
11!
301
            widget = self.widget
×
302
        placeholder = widget.attrs.get("placeholder", self.placeholder)
11✔
303
        if placeholder and self.set_placeholder and is_widget_with_placeholder(widget):
11✔
304
            # TODO: Should this be stripped and/or escaped?
305
            widget.attrs["placeholder"] = placeholder
11✔
306

307
    def add_help_attrs(self, widget=None):
11✔
308
        if widget is None:
11!
309
            widget = self.widget
×
310
        if not isinstance(widget, CheckboxInput):
11✔
311
            widget.attrs["title"] = widget.attrs.get("title", escape(strip_tags(self.field_help)))
11✔
312

313
    def add_widget_attrs(self):
11✔
314
        if self.is_multi_widget:
11✔
315
            widgets = self.widget.widgets
11✔
316
        else:
317
            widgets = [self.widget]
11✔
318
        for widget in widgets:
11✔
319
            self.add_class_attrs(widget)
11✔
320
            self.add_placeholder_attrs(widget)
11✔
321
            self.add_help_attrs(widget)
11✔
322

323
    def list_to_class(self, html, klass):
11✔
324
        classes = add_css_class(klass, self.get_size_class())
11✔
325
        soup = BeautifulSoup(html, features="html.parser")
11✔
326
        enclosing_div = soup.find("div")
11✔
327
        enclosing_div.attrs["class"] = classes
11✔
328
        for inner_div in enclosing_div.find_all("div"):
11✔
329
            inner_div.attrs["class"] = inner_div.attrs.get("class", []) + [self.form_check_class]
11✔
330
        # Apply bootstrap4 classes to labels and inputs.
331
        # A simple 'replace' isn't enough as we don't want to have several 'class' attr definition, which would happen
332
        # if we tried to 'html.replace("input", "input class=...")'
333
        enclosing_div = soup.find("div", {"class": classes})
11✔
334
        if enclosing_div:
11!
335
            for label in enclosing_div.find_all("label"):
11✔
336
                label.attrs["class"] = label.attrs.get("class", []) + ["form-check-label"]
11✔
337
                try:
11✔
338
                    label.input.attrs["class"] = label.input.attrs.get("class", []) + ["form-check-input"]
11✔
339
                except AttributeError:
11✔
340
                    pass
11✔
341
        return str(soup)
11✔
342

343
    def add_checkbox_label(self, html):
11✔
344
        return html + render_label(
11✔
345
            content=self.field.label,
346
            label_for=self.field.id_for_label,
347
            label_title=escape(strip_tags(self.field_help)),
348
            label_class="form-check-label",
349
        )
350

351
    def fix_date_select_input(self, html):
11✔
352
        div1 = '<div class="col-4">'
×
353
        div2 = "</div>"
×
354
        html = html.replace("<select", div1 + "<select")
×
355
        html = html.replace("</select>", "</select>" + div2)
×
356
        return f'<div class="row bootstrap4-multi-input">{html}</div>'
×
357

358
    def fix_file_input_label(self, html):
11✔
359
        if self.layout != "horizontal":
×
360
            html = "<br>" + html
×
361
        return html
×
362

363
    def post_widget_render(self, html):
11✔
364
        if isinstance(self.widget, CheckboxSelectMultiple):
11✔
365
            html = self.list_to_class(html, "checkbox")
11✔
366
        elif isinstance(self.widget, RadioSelect):
11✔
367
            html = self.list_to_class(html, "radio radio-success")
11✔
368
        elif isinstance(self.widget, SelectDateWidget):
11!
369
            html = self.fix_date_select_input(html)
×
370
        elif isinstance(self.widget, CheckboxInput):
11✔
371
            html = self.add_checkbox_label(html)
11✔
372
        elif isinstance(self.widget, FileInput):
11!
373
            html = self.fix_file_input_label(html)
×
374
        return html
11✔
375

376
    def wrap_widget(self, html):
11✔
377
        if isinstance(self.widget, CheckboxInput):
11✔
378
            # Wrap checkboxes
379
            # Note checkboxes do not get size classes, see #318
380
            html = f'<div class="form-check">{html}</div>'
11✔
381
        return html
11✔
382

383
    def make_input_group_addon(self, inner_class, outer_class, content):
11✔
384
        if not content:
11✔
385
            return ""
11✔
386
        if inner_class:
11✔
387
            content = f'<span class="{inner_class}">{content}</span>'
11✔
388
        return f'<div class="{outer_class}">{content}</div>'
11✔
389

390
    @property
11✔
391
    def is_input_group(self):
11✔
392
        allowed_widget_types = (TextInput, PasswordInput, DateInput, NumberInput, Select, EmailInput, URLInput)
11✔
393
        return (self.addon_before or self.addon_after) and isinstance(self.widget, allowed_widget_types)
11✔
394

395
    def make_input_group(self, html):
11✔
396
        if self.is_input_group:
11✔
397
            before = self.make_input_group_addon(self.addon_before_class, "input-group-prepend", self.addon_before)
11✔
398
            after = self.make_input_group_addon(self.addon_after_class, "input-group-append", self.addon_after)
11✔
399
            html = self.append_errors(f"{before}{html}{after}")
11✔
400
            html = f'<div class="input-group">{html}</div>'
11✔
401
        return html
11✔
402

403
    def append_help(self, html):
11✔
404
        field_help = self.field_help or None
11✔
405
        if field_help:
11✔
406
            help_html = render_template_file(
11✔
407
                "bootstrap4/field_help_text.html",
408
                context={
409
                    "field": self.field,
410
                    "field_help": field_help,
411
                    "layout": self.layout,
412
                    "show_help": self.show_help,
413
                },
414
            )
415
            html += help_html
11✔
416
        return html
11✔
417

418
    def append_errors(self, html):
11✔
419
        field_errors = self.field_errors
11✔
420
        if field_errors:
11✔
421
            errors_html = render_template_file(
11✔
422
                "bootstrap4/field_errors.html",
423
                context={
424
                    "field": self.field,
425
                    "field_errors": field_errors,
426
                    "layout": self.layout,
427
                    "show_help": self.show_help,
428
                },
429
            )
430
            html += errors_html
11✔
431
        return html
11✔
432

433
    def append_to_field(self, html):
11✔
434
        if isinstance(self.widget, CheckboxInput):
11✔
435
            # we have already appended errors and help to checkboxes
436
            # in append_to_checkbox_field
437
            return html
11✔
438

439
        if not self.is_input_group:
11✔
440
            # we already appended errors for input groups in make_input_group
441
            html = self.append_errors(html)
11✔
442

443
        return self.append_help(html)
11✔
444

445
    def append_to_checkbox_field(self, html):
11✔
446
        if not isinstance(self.widget, CheckboxInput):
11✔
447
            # we will append errors and help to normal fields later in append_to_field
448
            return html
11✔
449

450
        html = self.append_errors(html)
11✔
451
        return self.append_help(html)
11✔
452

453
    def get_field_class(self):
11✔
454
        field_class = self.field_class
11✔
455
        if not field_class and self.layout == "horizontal":
11✔
456
            field_class = self.horizontal_field_class
11✔
457
        return field_class
11✔
458

459
    def wrap_field(self, html):
11✔
460
        field_class = self.get_field_class()
11✔
461
        if field_class:
11✔
462
            html = f'<div class="{field_class}">{html}</div>'
11✔
463
        return html
11✔
464

465
    def get_label_class(self):
11✔
466
        label_class = self.label_class
11✔
467
        if not label_class and self.layout == "horizontal":
11✔
468
            label_class = self.horizontal_label_class
11✔
469
            label_class = add_css_class(label_class, "col-form-label")
11✔
470
        label_class = text_value(label_class)
11✔
471
        if not self.show_label or self.show_label == "sr-only":
11✔
472
            label_class = add_css_class(label_class, "sr-only")
11✔
473
        return label_class
11✔
474

475
    def get_label(self):
11✔
476
        if self.show_label == "skip":
11✔
477
            return None
11✔
478
        elif isinstance(self.widget, CheckboxInput):
11✔
479
            label = None
11✔
480
        else:
481
            label = self.field.label
11✔
482
        if self.layout == "horizontal" and not label:
11✔
483
            return mark_safe("&#160;")
11✔
484
        return label
11✔
485

486
    def add_label(self, html):
11✔
487
        label = self.get_label()
11✔
488
        if label:
11✔
489
            html = render_label(label, label_for=self.field.id_for_label, label_class=self.get_label_class()) + html
11✔
490
        return html
11✔
491

492
    def get_form_group_class(self):
11✔
493
        form_group_class = self.form_group_class
11✔
494
        if self.field.errors:
11✔
495
            if self.error_css_class:
11✔
496
                form_group_class = add_css_class(form_group_class, self.error_css_class)
11✔
497
        else:
498
            if self.field.form.is_bound:
11✔
499
                form_group_class = add_css_class(form_group_class, self.success_css_class)
11✔
500
        if self.field.field.required and self.required_css_class:
11✔
501
            form_group_class = add_css_class(form_group_class, self.required_css_class)
11✔
502
        if self.layout == "horizontal":
11✔
503
            form_group_class = add_css_class(form_group_class, "row")
11✔
504
        return form_group_class
11✔
505

506
    def wrap_label_and_field(self, html):
11✔
507
        return render_form_group(html, self.get_form_group_class())
11✔
508

509
    def _render(self):
11✔
510
        # See if we're not excluded
511
        if self.field.name in self.exclude.replace(" ", "").split(","):
11✔
512
            return ""
11✔
513
        # Hidden input requires no special treatment
514
        if self.field.is_hidden:
11✔
515
            return text_value(self.field)
11✔
516
        # Render the widget
517
        self.add_widget_attrs()
11✔
518
        html = self.field.as_widget(attrs=self.widget.attrs)
11✔
519
        self.restore_widget_attrs()
11✔
520
        # Start post render
521
        html = self.post_widget_render(html)
11✔
522
        html = self.append_to_checkbox_field(html)
11✔
523
        html = self.wrap_widget(html)
11✔
524
        html = self.make_input_group(html)
11✔
525
        html = self.append_to_field(html)
11✔
526
        html = self.wrap_field(html)
11✔
527
        html = self.add_label(html)
11✔
528
        html = self.wrap_label_and_field(html)
11✔
529
        return html
11✔
530

531

532
class InlineFieldRenderer(FieldRenderer):
11✔
533
    """Inline field renderer."""
534

535
    def add_error_attrs(self):
11✔
536
        field_title = self.widget.attrs.get("title", "")
×
537
        field_title += " " + " ".join([strip_tags(e) for e in self.field_errors])
×
538
        self.widget.attrs["title"] = field_title.strip()
×
539

540
    def add_widget_attrs(self):
11✔
541
        super().add_widget_attrs()
×
542
        self.add_error_attrs()
×
543

544
    def append_to_field(self, html):
11✔
545
        return html
×
546

547
    def get_field_class(self):
11✔
548
        return self.field_class
×
549

550
    def get_label_class(self):
11✔
551
        return add_css_class(self.label_class, "sr-only")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc