|  | 
|  | 1 | +from django import forms | 
|  | 2 | +from django.core.exceptions import ValidationError | 
|  | 3 | +from django.forms import formset_factory, model_to_dict | 
|  | 4 | +from django.forms.models import modelform_factory | 
|  | 5 | +from django.utils.html import format_html, format_html_join | 
|  | 6 | + | 
|  | 7 | + | 
|  | 8 | +class EmbeddedModelArrayField(forms.Field): | 
|  | 9 | + def __init__(self, model, *, prefix, max_num=None, extra_forms=3, **kwargs): | 
|  | 10 | + self.model = model | 
|  | 11 | + self.prefix = prefix | 
|  | 12 | + self.formset = formset_factory( | 
|  | 13 | + form=modelform_factory(model, fields="__all__"), | 
|  | 14 | + can_delete=True, | 
|  | 15 | + max_num=max_num, | 
|  | 16 | + extra=extra_forms, | 
|  | 17 | + validate_max=True, | 
|  | 18 | + ) | 
|  | 19 | + kwargs["widget"] = EmbeddedModelArrayWidget() | 
|  | 20 | + super().__init__(**kwargs) | 
|  | 21 | + | 
|  | 22 | + def clean(self, value): | 
|  | 23 | + if not value: | 
|  | 24 | + return [] | 
|  | 25 | + formset = self.formset(value, prefix=self.prefix_override or self.prefix) | 
|  | 26 | + if not formset.is_valid(): | 
|  | 27 | + raise ValidationError(formset.errors + formset.non_form_errors()) | 
|  | 28 | + cleaned_data = [] | 
|  | 29 | + for data in formset.cleaned_data: | 
|  | 30 | + # The "delete" checkbox isn't part of model data and must be | 
|  | 31 | + # removed. The fallback to True skips empty forms. | 
|  | 32 | + if data.pop("DELETE", True): | 
|  | 33 | + continue | 
|  | 34 | + cleaned_data.append(self.model(**data)) | 
|  | 35 | + return cleaned_data | 
|  | 36 | + | 
|  | 37 | + def has_changed(self, initial, data): | 
|  | 38 | + formset = self.formset(data, initial=models_to_dicts(initial), prefix=self.prefix) | 
|  | 39 | + return formset.has_changed() | 
|  | 40 | + | 
|  | 41 | + def get_bound_field(self, form, field_name): | 
|  | 42 | + # Nested embedded model form fields need a double prefix. | 
|  | 43 | + # HACK: Setting self.prefix_override makes it available in clean() | 
|  | 44 | + # which doesn't have access to the form. | 
|  | 45 | + self.prefix_override = f"{form.prefix}-{self.prefix}" if form.prefix else None | 
|  | 46 | + return EmbeddedModelArrayBoundField(form, self, field_name, self.prefix_override) | 
|  | 47 | + | 
|  | 48 | + | 
|  | 49 | +class EmbeddedModelArrayBoundField(forms.BoundField): | 
|  | 50 | + def __init__(self, form, field, name, prefix_override): | 
|  | 51 | + super().__init__(form, field, name) | 
|  | 52 | + self.formset = field.formset( | 
|  | 53 | + self.data if form.is_bound else None, | 
|  | 54 | + initial=models_to_dicts(self.initial), | 
|  | 55 | + prefix=prefix_override if prefix_override else self.html_name, | 
|  | 56 | + ) | 
|  | 57 | + | 
|  | 58 | + def __str__(self): | 
|  | 59 | + body = format_html_join( | 
|  | 60 | + "\n", "<tbody>{}</tbody>", ((form.as_table(),) for form in self.formset) | 
|  | 61 | + ) | 
|  | 62 | + return format_html("<table>\n{}\n</table>\n{}", body, self.formset.management_form) | 
|  | 63 | + | 
|  | 64 | + | 
|  | 65 | +class EmbeddedModelArrayWidget(forms.Widget): | 
|  | 66 | + """ | 
|  | 67 | + Extract the data for EmbeddedModelArrayFormField's formset. | 
|  | 68 | + This widget is never rendered. | 
|  | 69 | + """ | 
|  | 70 | + | 
|  | 71 | + def value_from_datadict(self, data, files, name): | 
|  | 72 | + return {field: value for field, value in data.items() if field.startswith(f"{name}-")} | 
|  | 73 | + | 
|  | 74 | + | 
|  | 75 | +def models_to_dicts(models): | 
|  | 76 | + """ | 
|  | 77 | + Convert initial data (which is a list of model instances or None) to a | 
|  | 78 | + list of dictionary data suitable for a formset. | 
|  | 79 | + """ | 
|  | 80 | + return [model_to_dict(model) for model in models or []] | 
0 commit comments