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.