Plain is headed towards 1.0! Subscribe for development updates →

Forms

Handle user input.

  • forms don't render themselves
  • registered scripts vs media?
  • move model save logic into form.save() - disconnect them
  • Form: validates input from request, saves it
  • Form field: validates field, knows how to parse from html data ?
  • starter includes form components - no markup in python

Re-using input styles

plain-elements, or includes

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