Plain is headed towards 1.0! Subscribe for development updates →

  1"""
  2Form classes
  3"""
  4
  5import copy
  6from functools import cached_property
  7
  8from plain.exceptions import NON_FIELD_ERRORS
  9
 10from .exceptions import ValidationError
 11from .fields import Field, FileField
 12
 13__all__ = ("BaseForm", "Form")
 14
 15
 16class DeclarativeFieldsMetaclass(type):
 17    """Collect Fields declared on the base classes."""
 18
 19    def __new__(mcs, name, bases, attrs):
 20        # Collect fields from current class and remove them from attrs.
 21        attrs["declared_fields"] = {
 22            key: attrs.pop(key)
 23            for key, value in list(attrs.items())
 24            if isinstance(value, Field)
 25        }
 26
 27        new_class = super().__new__(mcs, name, bases, attrs)
 28
 29        # Walk through the MRO.
 30        declared_fields = {}
 31        for base in reversed(new_class.__mro__):
 32            # Collect fields from base class.
 33            if hasattr(base, "declared_fields"):
 34                declared_fields.update(base.declared_fields)
 35
 36            # Field shadowing.
 37            for attr, value in base.__dict__.items():
 38                if value is None and attr in declared_fields:
 39                    declared_fields.pop(attr)
 40
 41        new_class.base_fields = declared_fields
 42        new_class.declared_fields = declared_fields
 43
 44        return new_class
 45
 46
 47class BaseForm:
 48    """
 49    The main implementation of all the Form logic. Note that this class is
 50    different than Form. See the comments by the Form class for more info. Any
 51    improvements to the form API should be made to this class, not to the Form
 52    class.
 53    """
 54
 55    prefix = None
 56
 57    def __init__(
 58        self,
 59        *,
 60        request,
 61        auto_id="id_%s",
 62        prefix=None,
 63        initial=None,
 64    ):
 65        self.data = request.data
 66        self.files = request.files
 67
 68        self.is_json_request = request.headers.get("Content-Type", "").startswith(
 69            "application/json"
 70        )
 71
 72        self.is_bound = bool(self.data or self.files)
 73
 74        self._auto_id = auto_id
 75        if prefix is not None:
 76            self.prefix = prefix
 77        self.initial = initial or {}
 78        self._errors = None  # Stores the errors after clean() has been called.
 79
 80        # The base_fields class attribute is the *class-wide* definition of
 81        # fields. Because a particular *instance* of the class might want to
 82        # alter self.fields, we create self.fields here by copying base_fields.
 83        # Instances should always modify self.fields; they should not modify
 84        # self.base_fields.
 85        self.fields = copy.deepcopy(self.base_fields)
 86        self._bound_fields_cache = {}
 87
 88    def __repr__(self):
 89        if self._errors is None:
 90            is_valid = "Unknown"
 91        else:
 92            is_valid = self.is_bound and not self._errors
 93        return "<{cls} bound={bound}, valid={valid}, fields=({fields})>".format(
 94            cls=self.__class__.__name__,
 95            bound=self.is_bound,
 96            valid=is_valid,
 97            fields=";".join(self.fields),
 98        )
 99
100    def _bound_items(self):
101        """Yield (name, bf) pairs, where bf is a BoundField object."""
102        for name in self.fields:
103            yield name, self[name]
104
105    def __iter__(self):
106        """Yield the form's fields as BoundField objects."""
107        for name in self.fields:
108            yield self[name]
109
110    def __getitem__(self, name):
111        """Return a BoundField with the given name."""
112        try:
113            field = self.fields[name]
114        except KeyError:
115            raise KeyError(
116                "Key '{}' not found in '{}'. Choices are: {}.".format(
117                    name,
118                    self.__class__.__name__,
119                    ", ".join(sorted(self.fields)),
120                )
121            )
122        if name not in self._bound_fields_cache:
123            self._bound_fields_cache[name] = field.get_bound_field(self, name)
124        return self._bound_fields_cache[name]
125
126    @property
127    def errors(self):
128        """Return an error dict for the data provided for the form."""
129        if self._errors is None:
130            self.full_clean()
131        return self._errors
132
133    def is_valid(self):
134        """Return True if the form has no errors, or False otherwise."""
135        return self.is_bound and not self.errors
136
137    def add_prefix(self, field_name):
138        """
139        Return the field name with a prefix appended, if this Form has a
140        prefix set.
141
142        Subclasses may wish to override.
143        """
144        return f"{self.prefix}-{field_name}" if self.prefix else field_name
145
146    @property
147    def non_field_errors(self):
148        """
149        Return a list of errors that aren't associated with a particular
150        field -- i.e., from Form.clean(). Return an empty list if there
151        are none.
152        """
153        return self.errors.get(
154            NON_FIELD_ERRORS,
155            [],
156        )
157
158    def add_error(self, field, error):
159        """
160        Update the content of `self._errors`.
161
162        The `field` argument is the name of the field to which the errors
163        should be added. If it's None, treat the errors as NON_FIELD_ERRORS.
164
165        The `error` argument can be a single error, a list of errors, or a
166        dictionary that maps field names to lists of errors. An "error" can be
167        either a simple string or an instance of ValidationError with its
168        message attribute set and a "list or dictionary" can be an actual
169        `list` or `dict` or an instance of ValidationError with its
170        `error_list` or `error_dict` attribute set.
171
172        If `error` is a dictionary, the `field` argument *must* be None and
173        errors will be added to the fields that correspond to the keys of the
174        dictionary.
175        """
176        if not isinstance(error, ValidationError):
177            raise TypeError(
178                "The argument `error` must be an instance of "
179                f"`ValidationError`, not `{type(error).__name__}`."
180            )
181
182        if hasattr(error, "error_dict"):
183            if field is not None:
184                raise TypeError(
185                    "The argument `field` must be `None` when the `error` "
186                    "argument contains errors for multiple fields."
187                )
188            else:
189                error = error.error_dict
190        else:
191            error = {field or NON_FIELD_ERRORS: error.error_list}
192
193        class ValidationErrors(list):
194            def __iter__(self):
195                for err in super().__iter__():
196                    # TODO make sure this works...
197                    yield next(iter(err))
198
199        for field, error_list in error.items():
200            if field not in self.errors:
201                if field != NON_FIELD_ERRORS and field not in self.fields:
202                    raise ValueError(
203                        f"'{self.__class__.__name__}' has no field named '{field}'."
204                    )
205                self._errors[field] = ValidationErrors()
206
207            self._errors[field].extend(error_list)
208
209            # The field had an error, so removed it from the final data
210            # (we use getattr here so errors can be added to uncleaned forms)
211            if field in getattr(self, "cleaned_data", {}):
212                del self.cleaned_data[field]
213
214    def full_clean(self):
215        """
216        Clean all of self.data and populate self._errors and self.cleaned_data.
217        """
218        self._errors = {}
219        if not self.is_bound:  # Stop further processing.
220            return
221        self.cleaned_data = {}
222
223        self._clean_fields()
224        self._clean_form()
225        self._post_clean()
226
227    def _field_data_value(self, field, html_name):
228        if hasattr(self, f"parse_{html_name}"):
229            # Allow custom parsing from form data/files at the form level
230            return getattr(self, f"parse_{html_name}")()
231
232        if self.is_json_request:
233            return field.value_from_json_data(self.data, self.files, html_name)
234        else:
235            return field.value_from_form_data(self.data, self.files, html_name)
236
237    def _clean_fields(self):
238        for name, bf in self._bound_items():
239            field = bf.field
240
241            value = self._field_data_value(bf.field, bf.html_name)
242
243            try:
244                if isinstance(field, FileField):
245                    value = field.clean(value, bf.initial)
246                else:
247                    value = field.clean(value)
248                self.cleaned_data[name] = value
249                if hasattr(self, f"clean_{name}"):
250                    value = getattr(self, f"clean_{name}")()
251                    self.cleaned_data[name] = value
252            except ValidationError as e:
253                self.add_error(name, e)
254
255    def _clean_form(self):
256        try:
257            cleaned_data = self.clean()
258        except ValidationError as e:
259            self.add_error(None, e)
260        else:
261            if cleaned_data is not None:
262                self.cleaned_data = cleaned_data
263
264    def _post_clean(self):
265        """
266        An internal hook for performing additional cleaning after form cleaning
267        is complete. Used for model validation in model forms.
268        """
269        pass
270
271    def clean(self):
272        """
273        Hook for doing any extra form-wide cleaning after Field.clean() has been
274        called on every field. Any ValidationError raised by this method will
275        not be associated with a particular field; it will have a special-case
276        association with the field named '__all__'.
277        """
278        return self.cleaned_data
279
280    @cached_property
281    def changed_data(self):
282        return [name for name, bf in self._bound_items() if bf._has_changed()]
283
284    def get_initial_for_field(self, field, field_name):
285        """
286        Return initial data for field on form. Use initial data from the form
287        or the field, in that order. Evaluate callable values.
288        """
289        value = self.initial.get(field_name, field.initial)
290        if callable(value):
291            value = value()
292        return value
293
294
295class Form(BaseForm, metaclass=DeclarativeFieldsMetaclass):
296    "A collection of Fields, plus their associated data."
297
298    # This is a separate class from BaseForm in order to abstract the way
299    # self.fields is specified. This class (Form) is the one that does the
300    # fancy metaclass stuff purely for the semantic sugar -- it allows one
301    # to define a form using declarative syntax.
302    # BaseForm itself has no way of designating self.fields.