1"""
2Helper functions for creating Form classes from Plain models
3and database field objects.
4"""
5
6from __future__ import annotations
7
8from itertools import chain
9from typing import TYPE_CHECKING, Any, cast
10
11from plain.exceptions import (
12 NON_FIELD_ERRORS,
13 ImproperlyConfigured,
14 ValidationError,
15)
16from plain.forms import fields
17from plain.forms.fields import ChoiceField, Field
18from plain.forms.forms import BaseForm, DeclarativeFieldsMetaclass
19from plain.models.exceptions import FieldError
20
21if TYPE_CHECKING:
22 from plain.models.fields import Field as ModelField
23
24__all__ = (
25 "ModelForm",
26 "BaseModelForm",
27 "model_to_dict",
28 "fields_for_model",
29 "ModelChoiceField",
30 "ModelMultipleChoiceField",
31)
32
33
34def construct_instance(
35 form: BaseModelForm,
36 instance: Any,
37 fields: list[str] | tuple[str, ...] | None = None,
38) -> Any:
39 """
40 Construct and return a model instance from the bound ``form``'s
41 ``cleaned_data``, but do not save the returned instance to the database.
42 """
43 from plain import models
44
45 meta = instance._model_meta
46
47 cleaned_data = form.cleaned_data
48 file_field_list = []
49 for f in meta.fields:
50 if isinstance(f, models.PrimaryKeyField) or f.name not in cleaned_data:
51 continue
52 if fields is not None and f.name not in fields:
53 continue
54 # Leave defaults for fields that aren't in POST data, except for
55 # checkbox inputs because they don't appear in POST data if not checked.
56 if (
57 f.has_default()
58 and form.add_prefix(f.name) not in form.data
59 and form.add_prefix(f.name) not in form.files
60 # and form[f.name].field.widget.value_omitted_from_data(
61 # form.data, form.files, form.add_prefix(f.name)
62 # )
63 and cleaned_data.get(f.name) in form[f.name].field.empty_values
64 ):
65 continue
66
67 f.save_form_data(instance, cleaned_data[f.name])
68
69 for f in file_field_list:
70 f.save_form_data(instance, cleaned_data[f.name])
71
72 return instance
73
74
75# ModelForms #################################################################
76
77
78def model_to_dict(
79 instance: Any, fields: list[str] | tuple[str, ...] | None = None
80) -> dict[str, Any]:
81 """
82 Return a dict containing the data in ``instance`` suitable for passing as
83 a Form's ``initial`` keyword argument.
84
85 ``fields`` is an optional list of field names. If provided, return only the
86 named.
87 """
88 meta = instance._model_meta
89 data = {}
90 for f in chain(meta.concrete_fields, meta.many_to_many):
91 if fields is not None and f.name not in fields:
92 continue
93 data[f.name] = f.value_from_object(instance)
94 return data
95
96
97def fields_for_model(
98 model: type[Any],
99 fields: list[str] | tuple[str, ...] | None = None,
100 formfield_callback: Any = None,
101 error_messages: dict[str, Any] | None = None,
102 field_classes: dict[str, type[Field]] | None = None,
103) -> dict[str, Field | None]:
104 """
105 Return a dictionary containing form fields for the given model.
106
107 ``fields`` is an optional list of field names. If provided, return only the
108 named fields.
109
110 ``formfield_callback`` is a callable that takes a model field and returns
111 a form field.
112
113 ``error_messages`` is a dictionary of model field names mapped to a
114 dictionary of error messages.
115
116 ``field_classes`` is a dictionary of model field names mapped to a form
117 field class.
118 """
119 field_dict = {}
120 ignored = []
121 meta = model._model_meta
122
123 for f in sorted(
124 chain(meta.concrete_fields, meta.many_to_many), key=lambda f: f.name
125 ):
126 if fields is not None and f.name not in fields:
127 continue
128
129 kwargs = {}
130 if error_messages and f.name in error_messages:
131 kwargs["error_messages"] = error_messages[f.name]
132 if field_classes and f.name in field_classes:
133 kwargs["form_class"] = field_classes[f.name]
134
135 if formfield_callback is None:
136 formfield = modelfield_to_formfield(f, **kwargs)
137 elif not callable(formfield_callback):
138 raise TypeError("formfield_callback must be a function or callable")
139 else:
140 formfield = formfield_callback(f, **kwargs)
141
142 if formfield:
143 field_dict[f.name] = formfield
144 else:
145 ignored.append(f.name)
146 if fields:
147 field_dict = {f: field_dict.get(f) for f in fields if f not in ignored}
148 return field_dict
149
150
151class ModelFormOptions:
152 def __init__(self, options: Any = None) -> None:
153 self.model: type[Any] | None = getattr(options, "model", None)
154 self.fields: list[str] | tuple[str, ...] | None = getattr(
155 options, "fields", None
156 )
157 self.error_messages: dict[str, Any] | None = getattr(
158 options, "error_messages", None
159 )
160 self.field_classes: dict[str, type[Field]] | None = getattr(
161 options, "field_classes", None
162 )
163 self.formfield_callback: Any = getattr(options, "formfield_callback", None)
164
165
166class ModelFormMetaclass(DeclarativeFieldsMetaclass):
167 def __new__(
168 mcs: type[ModelFormMetaclass],
169 name: str,
170 bases: tuple[type, ...],
171 attrs: dict[str, Any],
172 ) -> type[BaseModelForm]:
173 # Metaclass __new__ returns a type, specifically type[BaseModelForm]
174 new_class = cast(type[BaseModelForm], super().__new__(mcs, name, bases, attrs))
175
176 if bases == (BaseModelForm,):
177 return new_class
178
179 opts = new_class._meta = ModelFormOptions(getattr(new_class, "Meta", None))
180
181 # We check if a string was passed to `fields`,
182 # which is likely to be a mistake where the user typed ('foo') instead
183 # of ('foo',)
184 for opt in ["fields"]:
185 value = getattr(opts, opt)
186 if isinstance(value, str):
187 msg = (
188 f"{new_class.__name__}.Meta.{opt} cannot be a string. "
189 f"Did you mean to type: ('{value}',)?"
190 )
191 raise TypeError(msg)
192
193 if opts.model:
194 # If a model is defined, extract form fields from it.
195 if opts.fields is None:
196 raise ImproperlyConfigured(
197 "Creating a ModelForm without the 'fields' attribute "
198 f"is prohibited; form {name} "
199 "needs updating."
200 )
201
202 fields = fields_for_model(
203 opts.model,
204 opts.fields,
205 opts.formfield_callback,
206 opts.error_messages,
207 opts.field_classes,
208 )
209
210 # make sure opts.fields doesn't specify an invalid field
211 none_model_fields = {k for k, v in fields.items() if not v}
212 missing_fields = none_model_fields.difference(new_class.declared_fields)
213 if missing_fields:
214 message = "Unknown field(s) (%s) specified for %s"
215 message %= (", ".join(missing_fields), opts.model.__name__)
216 raise FieldError(message)
217 # Override default model fields with any custom declared ones
218 # (plus, include all the other declared fields).
219 fields.update(new_class.declared_fields)
220 else:
221 fields = new_class.declared_fields
222
223 # After validation and update, all fields should be non-None
224 new_class.base_fields = cast(dict[str, Field], fields)
225
226 return new_class
227
228
229class BaseModelForm(BaseForm):
230 # Set by DeclarativeFieldsMetaclass
231 declared_fields: dict[str, Field]
232 # Set by ModelFormMetaclass
233 _meta: ModelFormOptions
234
235 def __init__(
236 self,
237 *,
238 request: Any,
239 auto_id: str = "id_%s",
240 prefix: str | None = None,
241 initial: dict[str, Any] | None = None,
242 instance: Any = None,
243 ) -> None:
244 opts = self._meta
245 if opts.model is None:
246 raise ValueError("ModelForm has no model class specified.")
247 if instance is None:
248 # if we didn't get an instance, instantiate a new one
249 self.instance = opts.model()
250 object_data = {}
251 else:
252 self.instance = instance
253 object_data = model_to_dict(instance, opts.fields)
254 # if initial was provided, it should override the values from instance
255 if initial is not None:
256 object_data.update(initial)
257 # self._validate_unique will be set to True by BaseModelForm.clean().
258 # It is False by default so overriding self.clean() and failing to call
259 # super will stop validate_unique from being called.
260 self._validate_unique = False
261 super().__init__(
262 request=request,
263 auto_id=auto_id,
264 prefix=prefix,
265 initial=object_data,
266 )
267
268 def _get_validation_exclusions(self) -> set[str]:
269 """
270 For backwards-compatibility, exclude several types of fields from model
271 validation. See tickets #12507, #12521, #12553.
272 """
273 exclude = set()
274 # Build up a list of fields that should be excluded from model field
275 # validation and unique checks.
276 for f in self.instance._model_meta.fields:
277 field = f.name
278 # Exclude fields that aren't on the form. The developer may be
279 # adding these values to the model after form validation.
280 if field not in self.fields:
281 exclude.add(f.name)
282
283 # Don't perform model validation on fields that were defined
284 # manually on the form and excluded via the ModelForm's Meta
285 # class. See #12901.
286 elif self._meta.fields and field not in self._meta.fields:
287 exclude.add(f.name)
288
289 # Exclude fields that failed form validation. There's no need for
290 # the model fields to validate them as well.
291 elif self._errors and field in self._errors:
292 exclude.add(f.name)
293
294 # Exclude empty fields that are not required by the form, if the
295 # underlying model field is required. This keeps the model field
296 # from raising a required error. Note: don't exclude the field from
297 # validation if the model field allows blanks. If it does, the blank
298 # value may be included in a unique check, so cannot be excluded
299 # from validation.
300 else:
301 form_field = self.fields[field]
302 field_value = self.cleaned_data.get(field)
303 if (
304 f.required
305 and not form_field.required
306 and field_value in form_field.empty_values
307 ):
308 exclude.add(f.name)
309 return exclude
310
311 def clean(self) -> dict[str, Any]:
312 self._validate_unique = True
313 return self.cleaned_data
314
315 def _update_errors(self, errors: ValidationError) -> None:
316 # Override any validation error messages defined at the model level
317 # with those defined at the form level.
318 opts = self._meta
319
320 # Allow the model generated by construct_instance() to raise
321 # ValidationError and have them handled in the same way as others.
322 if hasattr(errors, "error_dict"):
323 error_dict = errors.error_dict
324 else:
325 error_dict = {NON_FIELD_ERRORS: errors}
326
327 for field, messages in error_dict.items():
328 if (
329 field == NON_FIELD_ERRORS
330 and opts.error_messages
331 and NON_FIELD_ERRORS in opts.error_messages
332 ):
333 error_messages = opts.error_messages[NON_FIELD_ERRORS]
334 elif field in self.fields:
335 error_messages = self.fields[field].error_messages
336 else:
337 continue
338
339 for message in messages:
340 if (
341 isinstance(message, ValidationError)
342 and message.code in error_messages
343 ):
344 message.message = error_messages[message.code]
345
346 self.add_error(None, errors)
347
348 def _post_clean(self) -> None:
349 opts = self._meta
350
351 exclude = self._get_validation_exclusions()
352
353 try:
354 self.instance = construct_instance(self, self.instance, opts.fields)
355 except ValidationError as e:
356 self._update_errors(e)
357
358 try:
359 self.instance.full_clean(exclude=exclude, validate_unique=False)
360 except ValidationError as e:
361 self._update_errors(e)
362
363 # Validate uniqueness if needed.
364 if self._validate_unique:
365 self.validate_unique()
366
367 def validate_unique(self) -> None:
368 """
369 Call the instance's validate_unique() method and update the form's
370 validation errors if any were raised.
371 """
372 exclude = self._get_validation_exclusions()
373 try:
374 self.instance.validate_unique(exclude=exclude)
375 except ValidationError as e:
376 self._update_errors(e)
377
378 def _save_m2m(self) -> None:
379 """
380 Save the many-to-many fields and generic relations for this form.
381 """
382 cleaned_data = self.cleaned_data
383 fields = self._meta.fields
384 meta = self.instance._model_meta
385
386 for f in meta.many_to_many:
387 if not hasattr(f, "save_form_data"):
388 continue
389 if fields and f.name not in fields:
390 continue
391 if f.name in cleaned_data:
392 f.save_form_data(self.instance, cleaned_data[f.name])
393
394 def save(self, commit: bool = True) -> Any:
395 """
396 Save this form's self.instance object if commit=True. Otherwise, add
397 a save_m2m() method to the form which can be called after the instance
398 is saved manually at a later time. Return the model instance.
399 """
400 if self.errors:
401 raise ValueError(
402 "The {} could not be {} because the data didn't validate.".format(
403 self.instance.model_options.object_name,
404 "created" if self.instance._state.adding else "changed",
405 )
406 )
407 if commit:
408 # If committing, save the instance and the m2m data immediately.
409 self.instance.save(clean_and_validate=False)
410 self._save_m2m()
411 else:
412 # If not committing, add a method to the form to allow deferred
413 # saving of m2m data.
414 self.save_m2m = self._save_m2m
415 return self.instance
416
417
418class ModelForm(BaseModelForm, metaclass=ModelFormMetaclass):
419 pass
420
421
422# Fields #####################################################################
423
424
425class ModelChoiceIteratorValue:
426 def __init__(self, value: Any, instance: Any) -> None:
427 self.value = value
428 self.instance = instance
429
430 def __str__(self) -> str:
431 return str(self.value)
432
433 def __hash__(self) -> int:
434 return hash(self.value)
435
436 def __eq__(self, other: object) -> bool:
437 if isinstance(other, ModelChoiceIteratorValue):
438 other = other.value
439 return self.value == other
440
441
442class ModelChoiceIterator:
443 def __init__(self, field: ModelChoiceField) -> None:
444 self.field = field
445 self.queryset = field.queryset
446
447 def __iter__(self) -> Any:
448 if self.field.empty_label is not None:
449 yield ("", self.field.empty_label)
450 queryset = self.queryset
451 # Can't use iterator() when queryset uses prefetch_related()
452 if not queryset._prefetch_related_lookups:
453 queryset = queryset.iterator()
454 for obj in queryset:
455 yield self.choice(obj)
456
457 def __len__(self) -> int:
458 # count() adds a query but uses less memory since the QuerySet results
459 # won't be cached. In most cases, the choices will only be iterated on,
460 # and __len__() won't be called.
461 return self.queryset.count() + (1 if self.field.empty_label is not None else 0)
462
463 def __bool__(self) -> bool:
464 return self.field.empty_label is not None or self.queryset.exists()
465
466 def choice(self, obj: Any) -> tuple[ModelChoiceIteratorValue, str]:
467 return (
468 ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
469 str(obj),
470 )
471
472
473class ModelChoiceField(ChoiceField):
474 """A ChoiceField whose choices are a model QuerySet."""
475
476 # This class is a subclass of ChoiceField for purity, but it doesn't
477 # actually use any of ChoiceField's implementation.
478 default_error_messages = {
479 "invalid_choice": "Select a valid choice. That choice is not one of the available choices.",
480 }
481 iterator = ModelChoiceIterator
482
483 def __init__(
484 self,
485 queryset: Any,
486 *,
487 empty_label: str | None = "---------",
488 required: bool = True,
489 initial: Any = None,
490 **kwargs: Any,
491 ) -> None:
492 # Call Field instead of ChoiceField __init__() because we don't need
493 # ChoiceField.__init__().
494 Field.__init__(
495 self,
496 required=required,
497 initial=initial,
498 **kwargs,
499 )
500 if required and initial is not None:
501 self.empty_label = None
502 else:
503 self.empty_label = empty_label
504 self.queryset = queryset
505
506 def __deepcopy__(self, memo: dict[int, Any]) -> ModelChoiceField:
507 result = super(ChoiceField, self).__deepcopy__(memo)
508 # Need to force a new ModelChoiceIterator to be created, bug #11183
509 if self.queryset is not None:
510 result.queryset = self.queryset.all()
511 return result
512
513 def _get_queryset(self) -> Any:
514 return self._queryset
515
516 def _set_queryset(self, queryset: Any) -> None:
517 self._queryset = None if queryset is None else queryset.all()
518
519 queryset = property(_get_queryset, _set_queryset)
520
521 def _get_choices(self) -> ModelChoiceIterator:
522 # If self._choices is set, then somebody must have manually set
523 # the property self.choices. In this case, just return self._choices.
524 if hasattr(self, "_choices"):
525 # After checking hasattr, we know _choices exists and is ModelChoiceIterator
526 return cast(ModelChoiceIterator, self._choices)
527
528 # Otherwise, execute the QuerySet in self.queryset to determine the
529 # choices dynamically. Return a fresh ModelChoiceIterator that has not been
530 # consumed. Note that we're instantiating a new ModelChoiceIterator *each*
531 # time _get_choices() is called (and, thus, each time self.choices is
532 # accessed) so that we can ensure the QuerySet has not been consumed. This
533 # construct might look complicated but it allows for lazy evaluation of
534 # the queryset.
535 return self.iterator(self)
536
537 choices = property(_get_choices, ChoiceField._set_choices)
538
539 def prepare_value(self, value: Any) -> Any:
540 if hasattr(value, "_model_meta"):
541 return value.id
542 return super().prepare_value(value)
543
544 def to_python(self, value: Any) -> Any:
545 if value in self.empty_values:
546 return None
547 try:
548 key = "id"
549 if isinstance(value, self.queryset.model):
550 value = getattr(value, key)
551 value = self.queryset.get(**{key: value})
552 except (ValueError, TypeError, self.queryset.model.DoesNotExist):
553 raise ValidationError(
554 self.error_messages["invalid_choice"],
555 code="invalid_choice",
556 params={"value": value},
557 )
558 return value
559
560 def validate(self, value: Any) -> None:
561 return Field.validate(self, value)
562
563 def has_changed(self, initial: Any, data: Any) -> bool:
564 initial_value = initial if initial is not None else ""
565 data_value = data if data is not None else ""
566 return str(self.prepare_value(initial_value)) != str(data_value)
567
568
569class ModelMultipleChoiceField(ModelChoiceField):
570 """A MultipleChoiceField whose choices are a model QuerySet."""
571
572 default_error_messages = {
573 "invalid_list": "Enter a list of values.",
574 "invalid_choice": "Select a valid choice. %(value)s is not one of the available choices.",
575 "invalid_id_value": "'%(id)s' is not a valid value.",
576 }
577
578 def __init__(self, queryset: Any, **kwargs: Any) -> None:
579 super().__init__(queryset, empty_label=None, **kwargs)
580
581 def to_python(self, value: Any) -> list[Any]: # type: ignore[override]
582 if not value:
583 return []
584 return list(self._check_values(value))
585
586 def clean(self, value: Any) -> Any:
587 value = self.prepare_value(value)
588 if self.required and not value:
589 raise ValidationError(self.error_messages["required"], code="required")
590 elif not self.required and not value:
591 return self.queryset.none()
592 if not isinstance(value, list | tuple):
593 raise ValidationError(
594 self.error_messages["invalid_list"],
595 code="invalid_list",
596 )
597 qs = self._check_values(value)
598 # Since this overrides the inherited ModelChoiceField.clean
599 # we run custom validators here
600 self.run_validators(value)
601 return qs
602
603 def _check_values(self, value: Any) -> Any:
604 """
605 Given a list of possible PK values, return a QuerySet of the
606 corresponding objects. Raise a ValidationError if a given value is
607 invalid (not a valid PK, not in the queryset, etc.)
608 """
609 # deduplicate given values to avoid creating many querysets or
610 # requiring the database backend deduplicate efficiently.
611 try:
612 value = frozenset(value)
613 except TypeError:
614 # list of lists isn't hashable, for example
615 raise ValidationError(
616 self.error_messages["invalid_list"],
617 code="invalid_list",
618 )
619 for id_val in value:
620 try:
621 self.queryset.filter(id=id_val)
622 except (ValueError, TypeError):
623 raise ValidationError(
624 self.error_messages["invalid_id_value"],
625 code="invalid_id_value",
626 params={"id": id_val},
627 )
628 qs = self.queryset.filter(id__in=value)
629 ids = {str(o.id) for o in qs}
630 for val in value:
631 if str(val) not in ids:
632 raise ValidationError(
633 self.error_messages["invalid_choice"],
634 code="invalid_choice",
635 params={"value": val},
636 )
637 return qs
638
639 def prepare_value(self, value: Any) -> Any:
640 if (
641 hasattr(value, "__iter__")
642 and not isinstance(value, str)
643 and not hasattr(value, "_model_meta")
644 ):
645 prepare_value = super().prepare_value
646 return [prepare_value(v) for v in value]
647 return super().prepare_value(value)
648
649 def has_changed(self, initial: Any, data: Any) -> bool:
650 if initial is None:
651 initial = []
652 if data is None:
653 data = []
654 if len(initial) != len(data):
655 return True
656 initial_set = {str(value) for value in self.prepare_value(initial)}
657 data_set = {str(value) for value in data}
658 return data_set != initial_set
659
660 def value_from_form_data(self, data: Any, files: Any, html_name: str) -> Any:
661 return data.getlist(html_name)
662
663
664def modelfield_to_formfield(
665 modelfield: ModelField,
666 form_class: type[Field] | None = None,
667 choices_form_class: type[Field] | None = None,
668 **kwargs: Any,
669) -> Field | None:
670 defaults: dict[str, Any] = {
671 "required": modelfield.required,
672 }
673
674 if modelfield.has_default():
675 defaults["initial"] = modelfield.get_default()
676
677 if modelfield.choices is not None:
678 # Fields with choices get special treatment.
679 include_blank = not modelfield.required or not (
680 modelfield.has_default() or "initial" in kwargs
681 )
682 defaults["choices"] = modelfield.get_choices(include_blank=include_blank)
683 defaults["coerce"] = modelfield.to_python
684 if modelfield.allow_null:
685 defaults["empty_value"] = None
686 if choices_form_class is not None:
687 form_class = choices_form_class
688 else:
689 form_class = fields.TypedChoiceField
690 # Many of the subclass-specific formfield arguments (min_value,
691 # max_value) don't apply for choice fields, so be sure to only pass
692 # the values that TypedChoiceField will understand.
693 for k in list(kwargs):
694 if k not in (
695 "coerce",
696 "empty_value",
697 "choices",
698 "required",
699 "initial",
700 "error_messages",
701 ):
702 del kwargs[k]
703
704 defaults.update(kwargs)
705
706 if form_class is not None:
707 return form_class(**defaults)
708
709 # Avoid a circular import
710 from plain import models
711
712 # Primary key fields aren't rendered by default
713 if isinstance(modelfield, models.PrimaryKeyField):
714 return None
715
716 if isinstance(modelfield, models.BooleanField):
717 form_class = (
718 fields.NullBooleanField if modelfield.allow_null else fields.BooleanField
719 )
720 # In HTML checkboxes, 'required' means "must be checked" which is
721 # different from the choices case ("must select some value").
722 # required=False allows unchecked checkboxes.
723 defaults["required"] = False
724 return form_class(**defaults)
725
726 if isinstance(modelfield, models.DecimalField):
727 return fields.DecimalField(
728 max_digits=modelfield.max_digits,
729 decimal_places=modelfield.decimal_places,
730 **defaults,
731 )
732
733 if issubclass(modelfield.__class__, models.fields.PositiveIntegerRelDbTypeMixin):
734 return fields.IntegerField(min_value=0, **defaults)
735
736 if isinstance(modelfield, models.TextField):
737 # Passing max_length to fields.CharField means that the value's length
738 # will be validated twice. This is considered acceptable since we want
739 # the value in the form field (to pass into widget for example).
740 return fields.CharField(max_length=modelfield.max_length, **defaults)
741
742 if isinstance(modelfield, models.CharField):
743 # Passing max_length to forms.CharField means that the value's length
744 # will be validated twice. This is considered acceptable since we want
745 # the value in the form field (to pass into widget for example).
746 if modelfield.allow_null:
747 defaults["empty_value"] = None
748 return fields.CharField(
749 max_length=modelfield.max_length,
750 **defaults,
751 )
752
753 if isinstance(modelfield, models.JSONField):
754 return fields.JSONField(
755 encoder=modelfield.encoder, decoder=modelfield.decoder, **defaults
756 )
757
758 if isinstance(modelfield, models.ForeignKeyField):
759 return ModelChoiceField(
760 queryset=modelfield.remote_field.model.query,
761 **defaults,
762 )
763
764 # TODO related (OneToOne, m2m)
765
766 # If there's a form field of the exact same name, use it
767 # (models.URLField -> forms.URLField)
768 if hasattr(fields, modelfield.__class__.__name__):
769 form_class = getattr(fields, modelfield.__class__.__name__)
770 return form_class(**defaults)
771
772 # Default to CharField if we didn't find anything else
773 return fields.CharField(**defaults)