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.