From 471596fc1afcb9c6258d317c619eaf5fd394e797 Mon Sep 17 00:00:00 2001 From: Joseph Kocherhans Date: Tue, 5 Jan 2010 03:56:19 +0000 Subject: [PATCH] Merged soc2009/model-validation to trunk. Thanks, Honza! git-svn-id: http://code.djangoproject.com/svn/django/trunk@12098 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- AUTHORS | 1 + django/contrib/admin/options.py | 16 +- django/contrib/auth/forms.py | 16 +- django/contrib/contenttypes/generic.py | 24 +- django/contrib/localflavor/ar/forms.py | 3 +- django/contrib/localflavor/au/forms.py | 5 +- django/contrib/localflavor/br/forms.py | 3 +- django/contrib/localflavor/ca/forms.py | 5 +- django/contrib/localflavor/ch/forms.py | 3 +- django/contrib/localflavor/cl/forms.py | 3 +- django/contrib/localflavor/cz/forms.py | 3 +- django/contrib/localflavor/de/forms.py | 3 +- django/contrib/localflavor/es/forms.py | 3 +- django/contrib/localflavor/fi/forms.py | 3 +- django/contrib/localflavor/fr/forms.py | 3 +- django/contrib/localflavor/id/forms.py | 3 +- django/contrib/localflavor/in_/forms.py | 3 +- django/contrib/localflavor/is_/forms.py | 3 +- django/contrib/localflavor/it/forms.py | 3 +- django/contrib/localflavor/kw/forms.py | 4 +- django/contrib/localflavor/nl/forms.py | 3 +- django/contrib/localflavor/no/forms.py | 3 +- django/contrib/localflavor/pe/forms.py | 3 +- django/contrib/localflavor/pt/forms.py | 3 +- django/contrib/localflavor/ro/forms.py | 2 +- django/contrib/localflavor/se/forms.py | 2 +- django/contrib/localflavor/us/forms.py | 3 +- django/contrib/localflavor/uy/forms.py | 3 +- django/contrib/localflavor/za/forms.py | 3 +- django/core/exceptions.py | 38 +- django/core/validators.py | 137 +++++++ django/db/models/base.py | 179 ++++++++- django/db/models/fields/__init__.py | 243 ++++++++---- django/db/models/fields/related.py | 33 +- django/forms/__init__.py | 2 +- django/forms/fields.py | 370 ++++++++---------- django/forms/forms.py | 3 +- django/forms/formsets.py | 3 +- django/forms/models.py | 239 +++-------- django/forms/util.py | 24 +- docs/ref/forms/fields.txt | 11 + docs/ref/forms/validation.txt | 111 ++++-- docs/ref/models/fields.txt | 22 ++ docs/ref/models/instances.txt | 25 ++ tests/modeltests/model_forms/models.py | 83 ++-- tests/modeltests/model_formsets/models.py | 17 - tests/modeltests/validation/__init__.py | 21 + tests/modeltests/validation/models.py | 53 +++ .../validation/test_custom_messages.py | 13 + tests/modeltests/validation/test_unique.py | 58 +++ tests/modeltests/validation/tests.py | 58 +++ tests/modeltests/validation/validators.py | 18 + tests/modeltests/validators/tests.py | 146 +++++++ tests/regressiontests/forms/error_messages.py | 24 +- tests/regressiontests/forms/fields.py | 6 +- tests/regressiontests/forms/localflavor/ar.py | 8 +- .../regressiontests/forms/localflavor/is_.py | 10 +- tests/regressiontests/forms/tests.py | 1 + tests/regressiontests/forms/util.py | 17 +- tests/regressiontests/forms/validators.py | 17 + .../regressiontests/inline_formsets/tests.py | 10 +- tests/regressiontests/model_fields/tests.py | 52 +++ tests/regressiontests/views/views.py | 2 +- 63 files changed, 1550 insertions(+), 639 deletions(-) create mode 100644 django/core/validators.py create mode 100644 tests/modeltests/validation/__init__.py create mode 100644 tests/modeltests/validation/models.py create mode 100644 tests/modeltests/validation/test_custom_messages.py create mode 100644 tests/modeltests/validation/test_unique.py create mode 100644 tests/modeltests/validation/tests.py create mode 100644 tests/modeltests/validation/validators.py create mode 100644 tests/modeltests/validators/tests.py create mode 100644 tests/regressiontests/forms/validators.py diff --git a/AUTHORS b/AUTHORS index ecedce26b7..6ad345a971 100644 --- a/AUTHORS +++ b/AUTHORS @@ -254,6 +254,7 @@ answer newbie questions, and generally made Django that much better: Gasper Koren Martin Kosír Arthur Koziel + Honza Kral Meir Kriheli Bruce Kroeze krzysiek.pawlik@silvermedia.pl diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 5c2a7c17f4..c18af6260a 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -578,12 +578,12 @@ class ModelAdmin(BaseModelAdmin): """ messages.info(request, message) - def save_form(self, request, form, change): + def save_form(self, request, form, change, commit=False): """ Given a ModelForm return an unsaved instance. ``change`` is True if the object is being changed, and False if it's being added. """ - return form.save(commit=False) + return form.save(commit=commit) def save_model(self, request, obj, form, change): """ @@ -757,8 +757,12 @@ class ModelAdmin(BaseModelAdmin): if request.method == 'POST': form = ModelForm(request.POST, request.FILES) if form.is_valid(): + # Save the object, even if inline formsets haven't been + # validated yet. We need to pass the valid model to the + # formsets for validation. If the formsets do not validate, we + # will delete the object. + new_object = self.save_form(request, form, change=False, commit=True) form_validated = True - new_object = self.save_form(request, form, change=False) else: form_validated = False new_object = self.model() @@ -774,13 +778,15 @@ class ModelAdmin(BaseModelAdmin): prefix=prefix, queryset=inline.queryset(request)) formsets.append(formset) if all_valid(formsets) and form_validated: - self.save_model(request, new_object, form, change=False) - form.save_m2m() for formset in formsets: self.save_formset(request, form, formset, change=False) self.log_addition(request, new_object) return self.response_add(request, new_object) + elif form_validated: + # The form was valid, but formsets were not, so delete the + # object we saved above. + new_object.delete() else: # Prepare the dict of initial data from the request. # We have to special-case M2Ms as a list of comma-separated PKs. diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index 55e770e553..dbc55ca0f9 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import User, UNUSABLE_PASSWORD from django.contrib.auth import authenticate from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.models import Site @@ -21,6 +21,12 @@ class UserCreationForm(forms.ModelForm): model = User fields = ("username",) + def clean(self): + # Fill the password field so model validation won't complain about it + # being blank. We'll set it with the real value below. + self.instance.password = UNUSABLE_PASSWORD + super(UserCreationForm, self).clean() + def clean_username(self): username = self.cleaned_data["username"] try: @@ -34,15 +40,9 @@ class UserCreationForm(forms.ModelForm): password2 = self.cleaned_data["password2"] if password1 != password2: raise forms.ValidationError(_("The two password fields didn't match.")) + self.instance.set_password(password1) return password2 - def save(self, commit=True): - user = super(UserCreationForm, self).save(commit=False) - user.set_password(self.cleaned_data["password1"]) - if commit: - user.save() - return user - class UserChangeForm(forms.ModelForm): username = forms.RegexField(label=_("Username"), max_length=30, regex=r'^\w+$', help_text = _("Required. 30 characters or fewer. Alphanumeric characters only (letters, digits and underscores)."), diff --git a/django/contrib/contenttypes/generic.py b/django/contrib/contenttypes/generic.py index 66fa9e6013..8a3ddfb52a 100644 --- a/django/contrib/contenttypes/generic.py +++ b/django/contrib/contenttypes/generic.py @@ -297,7 +297,11 @@ class BaseGenericInlineFormSet(BaseModelFormSet): # Avoid a circular import. from django.contrib.contenttypes.models import ContentType opts = self.model._meta - self.instance = instance + if instance is None: + self.instance = self.model() + else: + self.instance = instance + self.save_as_new = save_as_new self.rel_name = '-'.join(( opts.app_label, opts.object_name.lower(), self.ct_field.name, self.ct_fk_field.name, @@ -324,15 +328,19 @@ class BaseGenericInlineFormSet(BaseModelFormSet): )) get_default_prefix = classmethod(get_default_prefix) - def save_new(self, form, commit=True): + def _construct_form(self, i, **kwargs): # Avoid a circular import. from django.contrib.contenttypes.models import ContentType - kwargs = { - self.ct_field.get_attname(): ContentType.objects.get_for_model(self.instance).pk, - self.ct_fk_field.get_attname(): self.instance.pk, - } - new_obj = self.model(**kwargs) - return save_instance(form, new_obj, commit=commit) + form = super(BaseGenericInlineFormSet, self)._construct_form(i, **kwargs) + if self.save_as_new: + # Remove the key from the form's data, we are only creating new instances. + form.data[form.add_prefix(self.ct_fk_field.name)] = None + form.data[form.add_prefix(self.ct_field.name)] = None + + # Set the GenericForeignKey value here so that the form can do its validation. + setattr(form.instance, self.ct_fk_field.attname, self.instance.pk) + setattr(form.instance, self.ct_field.attname, ContentType.objects.get_for_model(self.instance).pk) + return form def generic_inlineformset_factory(model, form=ModelForm, formset=BaseGenericInlineFormSet, diff --git a/django/contrib/localflavor/ar/forms.py b/django/contrib/localflavor/ar/forms.py index 8e8e1af387..53721a17b6 100644 --- a/django/contrib/localflavor/ar/forms.py +++ b/django/contrib/localflavor/ar/forms.py @@ -4,7 +4,8 @@ AR-specific Form helpers. """ from django.forms import ValidationError -from django.forms.fields import RegexField, CharField, Select, EMPTY_VALUES +from django.core.validators import EMPTY_VALUES +from django.forms.fields import RegexField, CharField, Select from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ diff --git a/django/contrib/localflavor/au/forms.py b/django/contrib/localflavor/au/forms.py index afc3a0cc4c..87e04387f5 100644 --- a/django/contrib/localflavor/au/forms.py +++ b/django/contrib/localflavor/au/forms.py @@ -2,9 +2,10 @@ Australian-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES -from django.forms.util import smart_unicode +from django.forms.fields import Field, RegexField, Select +from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/br/forms.py b/django/contrib/localflavor/br/forms.py index 6d0a0384c6..9f482cd01f 100644 --- a/django/contrib/localflavor/br/forms.py +++ b/django/contrib/localflavor/br/forms.py @@ -3,8 +3,9 @@ BR-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, CharField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, CharField, Select from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/ca/forms.py b/django/contrib/localflavor/ca/forms.py index 542ff40670..ae3c76e24a 100644 --- a/django/contrib/localflavor/ca/forms.py +++ b/django/contrib/localflavor/ca/forms.py @@ -2,9 +2,10 @@ Canada-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES -from django.forms.util import smart_unicode +from django.forms.fields import Field, RegexField, Select +from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/ch/forms.py b/django/contrib/localflavor/ch/forms.py index bd92fcae98..eb1ae68844 100644 --- a/django/contrib/localflavor/ch/forms.py +++ b/django/contrib/localflavor/ch/forms.py @@ -2,8 +2,9 @@ Swiss-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/cl/forms.py b/django/contrib/localflavor/cl/forms.py index 61b3ab7aac..48219e88af 100644 --- a/django/contrib/localflavor/cl/forms.py +++ b/django/contrib/localflavor/cl/forms.py @@ -2,8 +2,9 @@ Chile specific form helpers. """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import RegexField, Select, EMPTY_VALUES +from django.forms.fields import RegexField, Select from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode diff --git a/django/contrib/localflavor/cz/forms.py b/django/contrib/localflavor/cz/forms.py index 6c7a5bf5dd..e980569c70 100644 --- a/django/contrib/localflavor/cz/forms.py +++ b/django/contrib/localflavor/cz/forms.py @@ -2,8 +2,9 @@ Czech-specific form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Select, RegexField, Field, EMPTY_VALUES +from django.forms.fields import Select, RegexField, Field from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/de/forms.py b/django/contrib/localflavor/de/forms.py index 7a1b7c03c8..a785a7194e 100644 --- a/django/contrib/localflavor/de/forms.py +++ b/django/contrib/localflavor/de/forms.py @@ -2,8 +2,9 @@ DE-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/es/forms.py b/django/contrib/localflavor/es/forms.py index a033f3e46b..8c4a19e614 100644 --- a/django/contrib/localflavor/es/forms.py +++ b/django/contrib/localflavor/es/forms.py @@ -3,8 +3,9 @@ Spanish-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import RegexField, Select, EMPTY_VALUES +from django.forms.fields import RegexField, Select from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/fi/forms.py b/django/contrib/localflavor/fi/forms.py index 2b82a796fe..9c163c9143 100644 --- a/django/contrib/localflavor/fi/forms.py +++ b/django/contrib/localflavor/fi/forms.py @@ -3,8 +3,9 @@ FI-specific Form helpers """ import re +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.translation import ugettext_lazy as _ class FIZipCodeField(RegexField): diff --git a/django/contrib/localflavor/fr/forms.py b/django/contrib/localflavor/fr/forms.py index 4cd84102a8..963eadc86a 100644 --- a/django/contrib/localflavor/fr/forms.py +++ b/django/contrib/localflavor/fr/forms.py @@ -2,8 +2,9 @@ FR-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/id/forms.py b/django/contrib/localflavor/id/forms.py index 0d68fa32d5..834e588749 100644 --- a/django/contrib/localflavor/id/forms.py +++ b/django/contrib/localflavor/id/forms.py @@ -5,8 +5,9 @@ ID-specific Form helpers import re import time +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, Select, EMPTY_VALUES +from django.forms.fields import Field, Select from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode diff --git a/django/contrib/localflavor/in_/forms.py b/django/contrib/localflavor/in_/forms.py index 270b0a09b1..0597623400 100644 --- a/django/contrib/localflavor/in_/forms.py +++ b/django/contrib/localflavor/in_/forms.py @@ -2,8 +2,9 @@ India-specific Form helpers. """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.encoding import smart_unicode from django.utils.translation import gettext import re diff --git a/django/contrib/localflavor/is_/forms.py b/django/contrib/localflavor/is_/forms.py index cab6eb18c0..abf858df2b 100644 --- a/django/contrib/localflavor/is_/forms.py +++ b/django/contrib/localflavor/is_/forms.py @@ -2,8 +2,9 @@ Iceland specific form helpers. """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import RegexField, EMPTY_VALUES +from django.forms.fields import RegexField from django.forms.widgets import Select from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode diff --git a/django/contrib/localflavor/it/forms.py b/django/contrib/localflavor/it/forms.py index d2d651955a..baa56a21ba 100644 --- a/django/contrib/localflavor/it/forms.py +++ b/django/contrib/localflavor/it/forms.py @@ -2,8 +2,9 @@ IT-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode from django.contrib.localflavor.it.util import ssn_check_digit, vat_number_check_digit diff --git a/django/contrib/localflavor/kw/forms.py b/django/contrib/localflavor/kw/forms.py index 32d6cb990a..94296255d6 100644 --- a/django/contrib/localflavor/kw/forms.py +++ b/django/contrib/localflavor/kw/forms.py @@ -3,8 +3,10 @@ Kuwait-specific Form helpers """ import re from datetime import date + +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, EMPTY_VALUES +from django.forms.fields import Field, RegexField from django.utils.translation import gettext as _ id_re = re.compile(r'^(?P\d{1})(?P\d\d)(?P\d\d)(?P
\d\d)(?P\d{4})(?P\d{1})') diff --git a/django/contrib/localflavor/nl/forms.py b/django/contrib/localflavor/nl/forms.py index 6dc5319eb7..997c28f609 100644 --- a/django/contrib/localflavor/nl/forms.py +++ b/django/contrib/localflavor/nl/forms.py @@ -4,8 +4,9 @@ NL-specific Form helpers import re +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, Select, EMPTY_VALUES +from django.forms.fields import Field, Select from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode diff --git a/django/contrib/localflavor/no/forms.py b/django/contrib/localflavor/no/forms.py index 0fe55adf7e..61a269c0fe 100644 --- a/django/contrib/localflavor/no/forms.py +++ b/django/contrib/localflavor/no/forms.py @@ -3,8 +3,9 @@ Norwegian-specific Form helpers """ import re, datetime +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.translation import ugettext_lazy as _ class NOZipCodeField(RegexField): diff --git a/django/contrib/localflavor/pe/forms.py b/django/contrib/localflavor/pe/forms.py index d83a2225be..7a4ac9e8d9 100644 --- a/django/contrib/localflavor/pe/forms.py +++ b/django/contrib/localflavor/pe/forms.py @@ -3,8 +3,9 @@ PE-specific Form helpers. """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import RegexField, CharField, Select, EMPTY_VALUES +from django.forms.fields import RegexField, CharField, Select from django.utils.translation import ugettext_lazy as _ class PERegionSelect(Select): diff --git a/django/contrib/localflavor/pt/forms.py b/django/contrib/localflavor/pt/forms.py index 86833fc85f..1f51679c4a 100644 --- a/django/contrib/localflavor/pt/forms.py +++ b/django/contrib/localflavor/pt/forms.py @@ -2,8 +2,9 @@ PT-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES +from django.forms.fields import Field, RegexField, Select from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/ro/forms.py b/django/contrib/localflavor/ro/forms.py index ca51d91839..dd86fce9f2 100644 --- a/django/contrib/localflavor/ro/forms.py +++ b/django/contrib/localflavor/ro/forms.py @@ -5,8 +5,8 @@ Romanian specific form helpers. import re +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError, Field, RegexField, Select -from django.forms.fields import EMPTY_VALUES from django.utils.translation import ugettext_lazy as _ class ROCIFField(RegexField): diff --git a/django/contrib/localflavor/se/forms.py b/django/contrib/localflavor/se/forms.py index eebd580c45..951f4f8d55 100644 --- a/django/contrib/localflavor/se/forms.py +++ b/django/contrib/localflavor/se/forms.py @@ -5,7 +5,7 @@ Swedish specific Form helpers import re from django import forms from django.utils.translation import ugettext_lazy as _ -from django.forms.fields import EMPTY_VALUES +from django.core.validators import EMPTY_VALUES from django.contrib.localflavor.se.utils import (id_number_checksum, validate_id_birthday, format_personal_id_number, valid_organisation, format_organisation_number) diff --git a/django/contrib/localflavor/us/forms.py b/django/contrib/localflavor/us/forms.py index cd18a7da9f..c426d36c66 100644 --- a/django/contrib/localflavor/us/forms.py +++ b/django/contrib/localflavor/us/forms.py @@ -2,8 +2,9 @@ USA-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, Select, EMPTY_VALUES, CharField +from django.forms.fields import Field, RegexField, Select, CharField from django.utils.encoding import smart_unicode from django.utils.translation import ugettext_lazy as _ import re diff --git a/django/contrib/localflavor/uy/forms.py b/django/contrib/localflavor/uy/forms.py index 5b47ba1e24..3f70cf336a 100644 --- a/django/contrib/localflavor/uy/forms.py +++ b/django/contrib/localflavor/uy/forms.py @@ -4,7 +4,8 @@ UY-specific form helpers. """ import re -from django.forms.fields import Select, RegexField, EMPTY_VALUES +from django.core.validators import EMPTY_VALUES +from django.forms.fields import Select, RegexField from django.forms import ValidationError from django.utils.translation import ugettext_lazy as _ from django.contrib.localflavor.uy.util import get_validation_digit diff --git a/django/contrib/localflavor/za/forms.py b/django/contrib/localflavor/za/forms.py index 7b7b714398..9a54f1ecb2 100644 --- a/django/contrib/localflavor/za/forms.py +++ b/django/contrib/localflavor/za/forms.py @@ -2,8 +2,9 @@ South Africa-specific Form helpers """ +from django.core.validators import EMPTY_VALUES from django.forms import ValidationError -from django.forms.fields import Field, RegexField, EMPTY_VALUES +from django.forms.fields import Field, RegexField from django.utils.checksums import luhn from django.utils.translation import gettext as _ import re diff --git a/django/core/exceptions.py b/django/core/exceptions.py index 1c21031739..fee7db4778 100644 --- a/django/core/exceptions.py +++ b/django/core/exceptions.py @@ -32,6 +32,42 @@ class FieldError(Exception): """Some kind of problem with a model field.""" pass -class ValidationError(Exception): +NON_FIELD_ERRORS = '__all__' +class BaseValidationError(Exception): """An error while validating data.""" + def __init__(self, message, code=None, params=None): + import operator + from django.utils.encoding import force_unicode + """ + ValidationError can be passed any object that can be printed (usually + a string), a list of objects or a dictionary. + """ + if isinstance(message, dict): + self.message_dict = message + # Reduce each list of messages into a single list. + message = reduce(operator.add, message.values()) + + if isinstance(message, list): + self.messages = [force_unicode(msg) for msg in message] + else: + self.code = code + self.params = params + message = force_unicode(message) + self.messages = [message] + + def __str__(self): + # This is needed because, without a __str__(), printing an exception + # instance would result in this: + # AttributeError: ValidationError instance has no attribute 'args' + # See http://www.python.org/doc/current/tut/node10.html#handling + if hasattr(self, 'message_dict'): + return repr(self.message_dict) + return repr(self.messages) + +class ValidationError(BaseValidationError): pass + +class UnresolvableValidationError(BaseValidationError): + """Validation error that cannot be resolved by the user.""" + pass + diff --git a/django/core/validators.py b/django/core/validators.py new file mode 100644 index 0000000000..6cd290fba9 --- /dev/null +++ b/django/core/validators.py @@ -0,0 +1,137 @@ +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_unicode + +# These values, if given to validate(), will trigger the self.required check. +EMPTY_VALUES = (None, '', [], (), {}) + +try: + from django.conf import settings + URL_VALIDATOR_USER_AGENT = settings.URL_VALIDATOR_USER_AGENT +except ImportError: + # It's OK if Django settings aren't configured. + URL_VALIDATOR_USER_AGENT = 'Django (http://www.djangoproject.com/)' + +class RegexValidator(object): + regex = '' + message = _(u'Enter a valid value.') + code = 'invalid' + + def __init__(self, regex=None, message=None, code=None): + if regex is not None: + self.regex = regex + if message is not None: + self.message = message + if code is not None: + self.code = code + + if isinstance(self.regex, basestring): + self.regex = re.compile(regex) + + def __call__(self, value): + """ + Validates that the input matches the regular expression. + """ + if not self.regex.search(smart_unicode(value)): + raise ValidationError(self.message, code=self.code) + +class URLValidator(RegexValidator): + regex = re.compile( + r'^https?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' #domain... + r'localhost|' #localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + + def __init__(self, verify_exists=False, validator_user_agent=URL_VALIDATOR_USER_AGENT): + super(URLValidator, self).__init__() + self.verify_exists = verify_exists + self.user_agent = validator_user_agent + + def __call__(self, value): + super(URLValidator, self).__call__(value) + if self.verify_exists: + import urllib2 + headers = { + "Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5", + "Accept-Language": "en-us,en;q=0.5", + "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.7", + "Connection": "close", + "User-Agent": self.user_agent, + } + try: + req = urllib2.Request(value, None, headers) + u = urllib2.urlopen(req) + except ValueError: + raise ValidationError(_(u'Enter a valid URL.'), code='invalid') + except: # urllib2.URLError, httplib.InvalidURL, etc. + raise ValidationError(_(u'This URL appears to be a broken link.'), code='invalid_link') + + +def validate_integer(value): + try: + int(value) + except (ValueError, TypeError), e: + raise ValidationError('') + + +email_re = re.compile( + r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom + r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*"' # quoted-string + r')@(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?$', re.IGNORECASE) # domain +validate_email = RegexValidator(email_re, _(u'Enter a valid e-mail address.'), 'invalid') + +slug_re = re.compile(r'^[-\w]+$') +validate_slug = RegexValidator(slug_re, _(u"Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."), 'invalid') + +ipv4_re = re.compile(r'^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$') +validate_ipv4_address = RegexValidator(ipv4_re, _(u'Enter a valid IPv4 address.'), 'invalid') + +comma_separated_int_list_re = re.compile('^[\d,]+$') +validate_comma_separated_integer_list = RegexValidator(comma_separated_int_list_re, _(u'Enter only digits separated by commas.'), 'invalid') + + +class BaseValidator(object): + compare = lambda self, a, b: a is not b + clean = lambda self, x: x + message = _(u'Ensure this value is %(limit_value)s (it is %(show_value)s).') + code = 'limit_value' + + def __init__(self, limit_value): + self.limit_value = limit_value + + def __call__(self, value): + cleaned = self.clean(value) + params = {'limit_value': self.limit_value, 'show_value': cleaned} + if self.compare(cleaned, self.limit_value): + raise ValidationError( + self.message % params, + code=self.code, + params=params, + ) + +class MaxValueValidator(BaseValidator): + compare = lambda self, a, b: a > b + message = _(u'Ensure this value is less than or equal to %(limit_value)s.') + code = 'max_value' + +class MinValueValidator(BaseValidator): + compare = lambda self, a, b: a < b + message = _(u'Ensure this value is greater than or equal to %(limit_value)s.') + code = 'min_value' + +class MinLengthValidator(BaseValidator): + compare = lambda self, a, b: a < b + clean = lambda self, x: len(x) + message = _(u'Ensure this value has at least %(limit_value)d characters (it has %(show_value)d).') + code = 'min_length' + +class MaxLengthValidator(BaseValidator): + compare = lambda self, a, b: a > b + clean = lambda self, x: len(x) + message = _(u'Ensure this value has at most %(limit_value)d characters (it has %(show_value)d).') + code = 'max_length' + diff --git a/django/db/models/base.py b/django/db/models/base.py index 3464ae6712..935933ae0e 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -3,7 +3,8 @@ import sys import os from itertools import izip import django.db.models.manager # Imported to register signal handler. -from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, FieldError +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, FieldError, ValidationError, NON_FIELD_ERRORS +from django.core import validators from django.db.models.fields import AutoField, FieldDoesNotExist from django.db.models.fields.related import OneToOneRel, ManyToOneRel, OneToOneField from django.db.models.query import delete_objects, Q @@ -12,9 +13,11 @@ from django.db.models.options import Options from django.db import connections, transaction, DatabaseError, DEFAULT_DB_ALIAS from django.db.models import signals from django.db.models.loading import register_models, get_model +from django.utils.translation import ugettext_lazy as _ import django.utils.copycompat as copy from django.utils.functional import curry from django.utils.encoding import smart_str, force_unicode, smart_unicode +from django.utils.text import get_text_list, capfirst from django.conf import settings class ModelBase(type): @@ -639,6 +642,180 @@ class Model(object): def prepare_database_save(self, unused): return self.pk + def validate(self): + """ + Hook for doing any extra model-wide validation after clean() has been + called on every field. Any ValidationError raised by this method will + not be associated with a particular field; it will have a special-case + association with the field defined by NON_FIELD_ERRORS. + """ + self.validate_unique() + + def validate_unique(self): + unique_checks, date_checks = self._get_unique_checks() + + errors = self._perform_unique_checks(unique_checks) + date_errors = self._perform_date_checks(date_checks) + + for k, v in date_errors.items(): + errors.setdefault(k, []).extend(v) + + if errors: + raise ValidationError(errors) + + def _get_unique_checks(self): + from django.db.models.fields import FieldDoesNotExist, Field as ModelField + + unique_checks = list(self._meta.unique_together) + # these are checks for the unique_for_ + date_checks = [] + + # Gather a list of checks for fields declared as unique and add them to + # the list of checks. Again, skip empty fields and any that did not validate. + for f in self._meta.fields: + name = f.name + if f.unique: + unique_checks.append((name,)) + if f.unique_for_date: + date_checks.append(('date', name, f.unique_for_date)) + if f.unique_for_year: + date_checks.append(('year', name, f.unique_for_year)) + if f.unique_for_month: + date_checks.append(('month', name, f.unique_for_month)) + return unique_checks, date_checks + + + def _perform_unique_checks(self, unique_checks): + errors = {} + + for unique_check in unique_checks: + # Try to look up an existing object with the same values as this + # object's values for all the unique field. + + lookup_kwargs = {} + for field_name in unique_check: + f = self._meta.get_field(field_name) + lookup_value = getattr(self, f.attname) + if f.null and lookup_value is None: + # no value, skip the lookup + continue + if f.primary_key and not getattr(self, '_adding', False): + # no need to check for unique primary key when editting + continue + lookup_kwargs[str(field_name)] = lookup_value + + # some fields were skipped, no reason to do the check + if len(unique_check) != len(lookup_kwargs.keys()): + continue + + qs = self.__class__._default_manager.filter(**lookup_kwargs) + + # Exclude the current object from the query if we are editing an + # instance (as opposed to creating a new one) + if not getattr(self, '_adding', False) and self.pk is not None: + qs = qs.exclude(pk=self.pk) + + # This cute trick with extra/values is the most efficient way to + # tell if a particular query returns any results. + if qs.extra(select={'a': 1}).values('a').order_by(): + if len(unique_check) == 1: + key = unique_check[0] + else: + key = NON_FIELD_ERRORS + errors.setdefault(key, []).append(self.unique_error_message(unique_check)) + + return errors + + def _perform_date_checks(self, date_checks): + errors = {} + for lookup_type, field, unique_for in date_checks: + lookup_kwargs = {} + # there's a ticket to add a date lookup, we can remove this special + # case if that makes it's way in + date = getattr(self, unique_for) + if lookup_type == 'date': + lookup_kwargs['%s__day' % unique_for] = date.day + lookup_kwargs['%s__month' % unique_for] = date.month + lookup_kwargs['%s__year' % unique_for] = date.year + else: + lookup_kwargs['%s__%s' % (unique_for, lookup_type)] = getattr(date, lookup_type) + lookup_kwargs[field] = getattr(self, field) + + qs = self.__class__._default_manager.filter(**lookup_kwargs) + # Exclude the current object from the query if we are editing an + # instance (as opposed to creating a new one) + if not getattr(self, '_adding', False) and self.pk is not None: + qs = qs.exclude(pk=self.pk) + + # This cute trick with extra/values is the most efficient way to + # tell if a particular query returns any results. + if qs.extra(select={'a': 1}).values('a').order_by(): + errors.setdefault(field, []).append( + self.date_error_message(lookup_type, field, unique_for) + ) + return errors + + def date_error_message(self, lookup_type, field, unique_for): + opts = self._meta + return _(u"%(field_name)s must be unique for %(date_field)s %(lookup)s.") % { + 'field_name': unicode(capfirst(opts.get_field(field).verbose_name)), + 'date_field': unicode(capfirst(opts.get_field(unique_for).verbose_name)), + 'lookup': lookup_type, + } + + def unique_error_message(self, unique_check): + opts = self._meta + model_name = capfirst(opts.verbose_name) + + # A unique field + if len(unique_check) == 1: + field_name = unique_check[0] + field_label = capfirst(opts.get_field(field_name).verbose_name) + # Insert the error into the error dict, very sneaky + return _(u"%(model_name)s with this %(field_label)s already exists.") % { + 'model_name': unicode(model_name), + 'field_label': unicode(field_label) + } + # unique_together + else: + field_labels = map(lambda f: capfirst(opts.get_field(f).verbose_name), unique_check) + field_labels = get_text_list(field_labels, _('and')) + return _(u"%(model_name)s with this %(field_label)s already exists.") % { + 'model_name': unicode(model_name), + 'field_label': unicode(field_labels) + } + + def full_validate(self, exclude=[]): + """ + Cleans all fields and raises ValidationError containing message_dict + of all validation errors if any occur. + """ + errors = {} + for f in self._meta.fields: + if f.name in exclude: + continue + try: + setattr(self, f.attname, f.clean(getattr(self, f.attname), self)) + except ValidationError, e: + errors[f.name] = e.messages + + # Form.clean() is run even if other validation fails, so do the + # same with Model.validate() for consistency. + try: + self.validate() + except ValidationError, e: + if hasattr(e, 'message_dict'): + if errors: + for k, v in e.message_dict.items(): + errors.set_default(k, []).extend(v) + else: + errors = e.message_dict + else: + errors[NON_FIELD_ERRORS] = e.messages + + if errors: + raise ValidationError(errors) + ############################################ # HELPER FUNCTIONS (CURRIED MODEL METHODS) # diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index b70f320df3..25f44a4d1c 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -13,12 +13,12 @@ from django.db.models.query_utils import QueryWrapper from django.dispatch import dispatcher from django.conf import settings from django import forms -from django.core import exceptions +from django.core import exceptions, validators from django.utils.datastructures import DictWrapper from django.utils.functional import curry from django.utils.itercompat import tee from django.utils.text import capfirst -from django.utils.translation import ugettext_lazy, ugettext as _ +from django.utils.translation import ugettext_lazy as _, ugettext from django.utils.encoding import smart_unicode, force_unicode, smart_str from django.utils import datetime_safe @@ -60,6 +60,12 @@ class Field(object): # creates, creation_counter is used for all user-specified fields. creation_counter = 0 auto_creation_counter = -1 + default_validators = [] # Default set of validators + default_error_messages = { + 'invalid_choice': _(u'Value %r is not a valid choice.'), + 'null': _(u'This field cannot be null.'), + 'blank': _(u'This field cannot be blank.'), + } # Generic field type description, usually overriden by subclasses def _description(self): @@ -73,7 +79,8 @@ class Field(object): db_index=False, rel=None, default=NOT_PROVIDED, editable=True, serialize=True, unique_for_date=None, unique_for_month=None, unique_for_year=None, choices=None, help_text='', db_column=None, - db_tablespace=None, auto_created=False): + db_tablespace=None, auto_created=False, validators=[], + error_messages=None): self.name = name self.verbose_name = verbose_name self.primary_key = primary_key @@ -106,6 +113,42 @@ class Field(object): self.creation_counter = Field.creation_counter Field.creation_counter += 1 + self.validators = self.default_validators + validators + + messages = {} + for c in reversed(self.__class__.__mro__): + messages.update(getattr(c, 'default_error_messages', {})) + messages.update(error_messages or {}) + self.error_messages = messages + + def __getstate__(self): + """ + Pickling support. + """ + from django.utils.functional import Promise + obj_dict = self.__dict__.copy() + items = [] + translated_keys = [] + for k, v in self.error_messages.items(): + if isinstance(v, Promise): + args = getattr(v, '_proxy____args', None) + if args: + translated_keys.append(k) + v = args[0] + items.append((k,v)) + obj_dict['_translated_keys'] = translated_keys + obj_dict['error_messages'] = dict(items) + return obj_dict + + def __setstate__(self, obj_dict): + """ + Unpickling support. + """ + translated_keys = obj_dict.pop('_translated_keys') + self.__dict__.update(obj_dict) + for k in translated_keys: + self.error_messages[k] = _(self.error_messages[k]) + def __cmp__(self, other): # This is needed because bisect does not take a comparison function. return cmp(self.creation_counter, other.creation_counter) @@ -127,6 +170,54 @@ class Field(object): """ return value + def run_validators(self, value): + if value in validators.EMPTY_VALUES: + return + + errors = [] + for v in self.validators: + try: + v(value) + except exceptions.ValidationError, e: + if hasattr(e, 'code') and e.code in self.error_messages: + message = self.error_messages[e.code] + if e.params: + message = message % e.params + errors.append(message) + else: + errors.extend(e.messages) + if errors: + raise exceptions.ValidationError(errors) + + def validate(self, value, model_instance): + """ + Validates value and throws ValidationError. Subclasses should override + this to provide validation logic. + """ + if not self.editable: + # Skip validation for non-editable fields. + return + if self._choices and value: + if not value in dict(self.choices): + raise exceptions.ValidationError(self.error_messages['invalid_choice'] % value) + + if value is None and not self.null: + raise exceptions.ValidationError(self.error_messages['null']) + + if not self.blank and value in validators.EMPTY_VALUES: + raise exceptions.ValidationError(self.error_messages['blank']) + + def clean(self, value, model_instance): + """ + Convert the value's type and run validation. Validation errors from to_python + and validate are propagated. The correct value is returned if no error is + raised. + """ + value = self.to_python(value) + self.validate(value, model_instance) + self.run_validators(value) + return value + def db_type(self, connection): """ Returns the database column data type for this field, for the provided @@ -377,9 +468,12 @@ class Field(object): return getattr(obj, self.attname) class AutoField(Field): - description = ugettext_lazy("Integer") + description = _("Integer") empty_strings_allowed = False + default_error_messages = { + 'invalid': _(u'This value must be an integer.'), + } def __init__(self, *args, **kwargs): assert kwargs.get('primary_key', False) is True, "%ss must have primary_key=True." % self.__class__.__name__ kwargs['blank'] = True @@ -391,8 +485,10 @@ class AutoField(Field): try: return int(value) except (TypeError, ValueError): - raise exceptions.ValidationError( - _("This value must be an integer.")) + raise exceptions.ValidationError(self.error_messages['invalid']) + + def validate(self, value, model_instance): + pass def get_prep_value(self, value): if value is None: @@ -410,7 +506,10 @@ class AutoField(Field): class BooleanField(Field): empty_strings_allowed = False - description = ugettext_lazy("Boolean (Either True or False)") + default_error_messages = { + 'invalid': _(u'This value must be either True or False.'), + } + description = _("Boolean (Either True or False)") def __init__(self, *args, **kwargs): kwargs['blank'] = True if 'default' not in kwargs and not kwargs.get('null'): @@ -424,8 +523,7 @@ class BooleanField(Field): if value in (True, False): return value if value in ('t', 'True', '1'): return True if value in ('f', 'False', '0'): return False - raise exceptions.ValidationError( - _("This value must be either True or False.")) + raise exceptions.ValidationError(self.error_messages['invalid']) def get_prep_lookup(self, lookup_type, value): # Special-case handling for filters coming from a web request (e.g. the @@ -453,36 +551,35 @@ class BooleanField(Field): return super(BooleanField, self).formfield(**defaults) class CharField(Field): - description = ugettext_lazy("String (up to %(max_length)s)") + description = _("String (up to %(max_length)s)") + + def __init__(self, *args, **kwargs): + super(CharField, self).__init__(*args, **kwargs) + self.validators.append(validators.MaxLengthValidator(self.max_length)) def get_internal_type(self): return "CharField" def to_python(self, value): - if isinstance(value, basestring): + if isinstance(value, basestring) or value is None: return value - if value is None: - if self.null: - return value - else: - raise exceptions.ValidationError( - ugettext_lazy("This field cannot be null.")) return smart_unicode(value) def formfield(self, **kwargs): + # Passing max_length to forms.CharField means that the value's length + # will be validated twice. This is considered acceptable since we want + # the value in the form field (to pass into widget for example). defaults = {'max_length': self.max_length} defaults.update(kwargs) return super(CharField, self).formfield(**defaults) # TODO: Maybe move this into contrib, because it's specialized. class CommaSeparatedIntegerField(CharField): - description = ugettext_lazy("Comma-separated integers") + default_validators = [validators.validate_comma_separated_integer_list] + description = _("Comma-separated integers") def formfield(self, **kwargs): defaults = { - 'form_class': forms.RegexField, - 'regex': '^[\d,]+$', - 'max_length': self.max_length, 'error_messages': { 'invalid': _(u'Enter only digits separated by commas.'), } @@ -493,9 +590,13 @@ class CommaSeparatedIntegerField(CharField): ansi_date_re = re.compile(r'^\d{4}-\d{1,2}-\d{1,2}$') class DateField(Field): - description = ugettext_lazy("Date (without time)") + description = _("Date (without time)") empty_strings_allowed = False + default_error_messages = { + 'invalid': _('Enter a valid date in YYYY-MM-DD format.'), + 'invalid_date': _('Invalid date: %s'), + } def __init__(self, verbose_name=None, name=None, auto_now=False, auto_now_add=False, **kwargs): self.auto_now, self.auto_now_add = auto_now, auto_now_add #HACKs : auto_now_add/auto_now should be done as a default or a pre_save. @@ -516,8 +617,7 @@ class DateField(Field): return value if not ansi_date_re.search(value): - raise exceptions.ValidationError( - _('Enter a valid date in YYYY-MM-DD format.')) + raise exceptions.ValidationError(self.error_messages['invalid']) # Now that we have the date string in YYYY-MM-DD format, check to make # sure it's a valid date. # We could use time.strptime here and catch errors, but datetime.date @@ -526,7 +626,7 @@ class DateField(Field): try: return datetime.date(year, month, day) except ValueError, e: - msg = _('Invalid date: %s') % _(str(e)) + msg = self.error_messages['invalid_date'] % _(str(e)) raise exceptions.ValidationError(msg) def pre_save(self, model_instance, add): @@ -575,7 +675,10 @@ class DateField(Field): return super(DateField, self).formfield(**defaults) class DateTimeField(DateField): - description = ugettext_lazy("Date (with time)") + default_error_messages = { + 'invalid': _(u'Enter a valid date/time in YYYY-MM-DD HH:MM[:ss[.uuuuuu]] format.'), + } + description = _("Date (with time)") def get_internal_type(self): return "DateTimeField" @@ -596,8 +699,7 @@ class DateTimeField(DateField): value, usecs = value.split('.') usecs = int(usecs) except ValueError: - raise exceptions.ValidationError( - _('Enter a valid date/time in YYYY-MM-DD HH:MM[:ss[.uuuuuu]] format.')) + raise exceptions.ValidationError(self.error_messages['invalid']) else: usecs = 0 kwargs = {'microsecond': usecs} @@ -614,8 +716,7 @@ class DateTimeField(DateField): return datetime.datetime(*time.strptime(value, '%Y-%m-%d')[:3], **kwargs) except ValueError: - raise exceptions.ValidationError( - _('Enter a valid date/time in YYYY-MM-DD HH:MM[:ss[.uuuuuu]] format.')) + raise exceptions.ValidationError(self.error_messages['invalid']) def get_prep_value(self, value): return self.to_python(value) @@ -642,7 +743,11 @@ class DateTimeField(DateField): class DecimalField(Field): empty_strings_allowed = False - description = ugettext_lazy("Decimal number") + default_error_messages = { + 'invalid': _(u'This value must be a decimal number.'), + } + description = _("Decimal number") + def __init__(self, verbose_name=None, name=None, max_digits=None, decimal_places=None, **kwargs): self.max_digits, self.decimal_places = max_digits, decimal_places Field.__init__(self, verbose_name, name, **kwargs) @@ -656,8 +761,7 @@ class DecimalField(Field): try: return decimal.Decimal(value) except decimal.InvalidOperation: - raise exceptions.ValidationError( - _("This value must be a decimal number.")) + raise exceptions.ValidationError(self.error_messages['invalid']) def _format(self, value): if isinstance(value, basestring) or value is None: @@ -696,18 +800,15 @@ class DecimalField(Field): return super(DecimalField, self).formfield(**defaults) class EmailField(CharField): - description = ugettext_lazy("E-mail address") + default_validators = [validators.validate_email] + description = _("E-mail address") + def __init__(self, *args, **kwargs): kwargs['max_length'] = kwargs.get('max_length', 75) CharField.__init__(self, *args, **kwargs) - def formfield(self, **kwargs): - defaults = {'form_class': forms.EmailField} - defaults.update(kwargs) - return super(EmailField, self).formfield(**defaults) - class FilePathField(Field): - description = ugettext_lazy("File path") + description = _("File path") def __init__(self, verbose_name=None, name=None, path='', match=None, recursive=False, **kwargs): self.path, self.match, self.recursive = path, match, recursive @@ -729,7 +830,10 @@ class FilePathField(Field): class FloatField(Field): empty_strings_allowed = False - description = ugettext_lazy("Floating point number") + default_error_messages = { + 'invalid': _("This value must be a float."), + } + description = _("Floating point number") def get_prep_value(self, value): if value is None: @@ -745,8 +849,7 @@ class FloatField(Field): try: return float(value) except (TypeError, ValueError): - raise exceptions.ValidationError( - _("This value must be a float.")) + raise exceptions.ValidationError(self.error_messages['invalid']) def formfield(self, **kwargs): defaults = {'form_class': forms.FloatField} @@ -755,7 +858,10 @@ class FloatField(Field): class IntegerField(Field): empty_strings_allowed = False - description = ugettext_lazy("Integer") + default_error_messages = { + 'invalid': _("This value must be a float."), + } + description = _("Integer") def get_prep_value(self, value): if value is None: @@ -771,8 +877,7 @@ class IntegerField(Field): try: return int(value) except (TypeError, ValueError): - raise exceptions.ValidationError( - _("This value must be an integer.")) + raise exceptions.ValidationError(self.error_messages['invalid']) def formfield(self, **kwargs): defaults = {'form_class': forms.IntegerField} @@ -781,7 +886,7 @@ class IntegerField(Field): class BigIntegerField(IntegerField): empty_strings_allowed = False - description = ugettext_lazy("Big (8 byte) integer") + description = _("Big (8 byte) integer") MAX_BIGINT = 9223372036854775807 def get_internal_type(self): return "BigIntegerField" @@ -794,7 +899,7 @@ class BigIntegerField(IntegerField): class IPAddressField(Field): empty_strings_allowed = False - description = ugettext_lazy("IP address") + description = _("IP address") def __init__(self, *args, **kwargs): kwargs['max_length'] = 15 Field.__init__(self, *args, **kwargs) @@ -809,7 +914,11 @@ class IPAddressField(Field): class NullBooleanField(Field): empty_strings_allowed = False - description = ugettext_lazy("Boolean (Either True, False or None)") + default_error_messages = { + 'invalid': _("This value must be either None, True or False."), + } + description = _("Boolean (Either True, False or None)") + def __init__(self, *args, **kwargs): kwargs['null'] = True Field.__init__(self, *args, **kwargs) @@ -822,8 +931,7 @@ class NullBooleanField(Field): if value in ('None',): return None if value in ('t', 'True', '1'): return True if value in ('f', 'False', '0'): return False - raise exceptions.ValidationError( - _("This value must be either None, True or False.")) + raise exceptions.ValidationError(self.error_messages['invalid']) def get_prep_lookup(self, lookup_type, value): # Special-case handling for filters coming from a web request (e.g. the @@ -849,7 +957,7 @@ class NullBooleanField(Field): return super(NullBooleanField, self).formfield(**defaults) class PositiveIntegerField(IntegerField): - description = ugettext_lazy("Integer") + description = _("Integer") def get_internal_type(self): return "PositiveIntegerField" @@ -860,7 +968,7 @@ class PositiveIntegerField(IntegerField): return super(PositiveIntegerField, self).formfield(**defaults) class PositiveSmallIntegerField(IntegerField): - description = ugettext_lazy("Integer") + description = _("Integer") def get_internal_type(self): return "PositiveSmallIntegerField" @@ -870,7 +978,7 @@ class PositiveSmallIntegerField(IntegerField): return super(PositiveSmallIntegerField, self).formfield(**defaults) class SlugField(CharField): - description = ugettext_lazy("String (up to %(max_length)s)") + description = _("String (up to %(max_length)s)") def __init__(self, *args, **kwargs): kwargs['max_length'] = kwargs.get('max_length', 50) # Set db_index=True unless it's been set manually. @@ -887,13 +995,13 @@ class SlugField(CharField): return super(SlugField, self).formfield(**defaults) class SmallIntegerField(IntegerField): - description = ugettext_lazy("Integer") + description = _("Integer") def get_internal_type(self): return "SmallIntegerField" class TextField(Field): - description = ugettext_lazy("Text") + description = _("Text") def get_internal_type(self): return "TextField" @@ -904,9 +1012,12 @@ class TextField(Field): return super(TextField, self).formfield(**defaults) class TimeField(Field): - description = ugettext_lazy("Time") + description = _("Time") empty_strings_allowed = False + default_error_messages = { + 'invalid': _('Enter a valid time in HH:MM[:ss[.uuuuuu]] format.'), + } def __init__(self, verbose_name=None, name=None, auto_now=False, auto_now_add=False, **kwargs): self.auto_now, self.auto_now_add = auto_now, auto_now_add if auto_now or auto_now_add: @@ -935,8 +1046,7 @@ class TimeField(Field): value, usecs = value.split('.') usecs = int(usecs) except ValueError: - raise exceptions.ValidationError( - _('Enter a valid time in HH:MM[:ss[.uuuuuu]] format.')) + raise exceptions.ValidationError(self.error_messages['invalid']) else: usecs = 0 kwargs = {'microsecond': usecs} @@ -949,8 +1059,7 @@ class TimeField(Field): return datetime.time(*time.strptime(value, '%H:%M')[3:5], **kwargs) except ValueError: - raise exceptions.ValidationError( - _('Enter a valid time in HH:MM[:ss[.uuuuuu]] format.')) + raise exceptions.ValidationError(self.error_messages['invalid']) def pre_save(self, model_instance, add): if self.auto_now or (self.auto_now_add and add): @@ -983,21 +1092,17 @@ class TimeField(Field): return super(TimeField, self).formfield(**defaults) class URLField(CharField): - description = ugettext_lazy("URL") + description = _("URL") def __init__(self, verbose_name=None, name=None, verify_exists=True, **kwargs): kwargs['max_length'] = kwargs.get('max_length', 200) - self.verify_exists = verify_exists CharField.__init__(self, verbose_name, name, **kwargs) - - def formfield(self, **kwargs): - defaults = {'form_class': forms.URLField, 'verify_exists': self.verify_exists} - defaults.update(kwargs) - return super(URLField, self).formfield(**defaults) + self.validators.append(validators.URLValidator(verify_exists=verify_exists)) class XMLField(TextField): - description = ugettext_lazy("XML text") + description = _("XML text") def __init__(self, verbose_name=None, name=None, schema_path=None, **kwargs): self.schema_path = schema_path Field.__init__(self, verbose_name, name, **kwargs) + diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 7cc9a03907..749bdcf39a 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -7,7 +7,7 @@ from django.db.models.related import RelatedObject from django.db.models.query import QuerySet from django.db.models.query_utils import QueryWrapper from django.utils.encoding import smart_unicode -from django.utils.translation import ugettext_lazy, string_concat, ungettext, ugettext as _ +from django.utils.translation import ugettext_lazy as _, string_concat, ungettext, ugettext from django.utils.functional import curry from django.core import exceptions from django import forms @@ -473,7 +473,7 @@ def create_many_related_manager(superclass, rel=False): if not rel.through._meta.auto_created: opts = through._meta raise AttributeError, "Cannot use create() on a ManyToManyField which specifies an intermediary model. Use %s.%s's Manager instead." % (opts.app_label, opts.object_name) - new_obj = super(ManyRelatedManager, self).using(self.instance._state.db).create(**kwargs) + new_obj = super(ManyRelatedManager, self).create(**kwargs) self.add(new_obj) return new_obj create.alters_data = True @@ -708,7 +708,10 @@ class ManyToManyRel(object): class ForeignKey(RelatedField, Field): empty_strings_allowed = False - description = ugettext_lazy("Foreign Key (type determined by related field)") + default_error_messages = { + 'invalid': _('Model %(model)s with pk %(pk)r does not exist.') + } + description = _("Foreign Key (type determined by related field)") def __init__(self, to, to_field=None, rel_class=ManyToOneRel, **kwargs): try: to_name = to._meta.object_name.lower() @@ -731,6 +734,18 @@ class ForeignKey(RelatedField, Field): self.db_index = True + def validate(self, value, model_instance): + if self.rel.parent_link: + return + super(ForeignKey, self).validate(value, model_instance) + if not value: + return + try: + self.rel.to._default_manager.get(**{self.rel.field_name:value}) + except self.rel.to.DoesNotExist, e: + raise exceptions.ValidationError( + self.error_messages['invalid'] % {'model': self.rel.to._meta.verbose_name, 'pk': value}) + def get_attname(self): return '%s_id' % self.name @@ -812,7 +827,7 @@ class OneToOneField(ForeignKey): always returns the object pointed to (since there will only ever be one), rather than returning a list. """ - description = ugettext_lazy("One-to-one relationship") + description = _("One-to-one relationship") def __init__(self, to, to_field=None, **kwargs): kwargs['unique'] = True super(OneToOneField, self).__init__(to, to_field, OneToOneRel, **kwargs) @@ -826,6 +841,12 @@ class OneToOneField(ForeignKey): return None return super(OneToOneField, self).formfield(**kwargs) + def save_form_data(self, instance, data): + if isinstance(data, self.rel.to): + setattr(instance, self.name, data) + else: + setattr(instance, self.attname, data) + def create_many_to_many_intermediary_model(field, klass): from django.db import models managed = True @@ -866,7 +887,7 @@ def create_many_to_many_intermediary_model(field, klass): }) class ManyToManyField(RelatedField, Field): - description = ugettext_lazy("Many-to-many relationship") + description = _("Many-to-many relationship") def __init__(self, to, **kwargs): try: assert not to._meta.abstract, "%s cannot define a relation with abstract class %s" % (self.__class__.__name__, to._meta.object_name) @@ -886,7 +907,7 @@ class ManyToManyField(RelatedField, Field): Field.__init__(self, **kwargs) - msg = ugettext_lazy('Hold down "Control", or "Command" on a Mac, to select more than one.') + msg = _('Hold down "Control", or "Command" on a Mac, to select more than one.') self.help_text = string_concat(self.help_text, ' ', msg) def get_choices_default(self): diff --git a/django/forms/__init__.py b/django/forms/__init__.py index 0d9c68f9e0..dc8b5212c4 100644 --- a/django/forms/__init__.py +++ b/django/forms/__init__.py @@ -10,7 +10,7 @@ TODO: "This form field requires foo.js" and form.js_includes() """ -from util import ValidationError +from django.core.exceptions import ValidationError from widgets import * from fields import * from forms import * diff --git a/django/forms/fields.py b/django/forms/fields.py index 1194196fd1..2e6eb821f3 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -14,15 +14,21 @@ try: except ImportError: from StringIO import StringIO -import django.core.exceptions +from django.core.exceptions import ValidationError +from django.core import validators import django.utils.copycompat as copy from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_unicode, smart_str from django.utils.formats import get_format from django.utils.functional import lazy -from util import ErrorList, ValidationError -from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, DateInput, DateTimeInput, TimeInput, SplitDateTimeWidget, SplitHiddenDateTimeWidget +# Provide this import for backwards compatibility. +from django.core.validators import EMPTY_VALUES + +from util import ErrorList +from widgets import TextInput, PasswordInput, HiddenInput, MultipleHiddenInput, \ + FileInput, CheckboxInput, Select, NullBooleanSelect, SelectMultiple, \ + DateInput, DateTimeInput, TimeInput, SplitDateTimeWidget, SplitHiddenDateTimeWidget __all__ = ( 'Field', 'CharField', 'IntegerField', @@ -36,9 +42,6 @@ __all__ = ( 'TypedChoiceField' ) -# These values, if given to to_python(), will trigger the self.required check. -EMPTY_VALUES = (None, '') - def en_format(name): """ Helper function to stay backward compatible. @@ -57,6 +60,7 @@ DEFAULT_DATETIME_INPUT_FORMATS = lazy(lambda: en_format('DATETIME_INPUT_FORMATS' class Field(object): widget = TextInput # Default widget to use when rendering this type of Field. hidden_widget = HiddenInput # Default widget to use when rendering this as "hidden". + default_validators = [] # Default set of validators default_error_messages = { 'required': _(u'This field is required.'), 'invalid': _(u'Enter a valid value.'), @@ -66,7 +70,8 @@ class Field(object): creation_counter = 0 def __init__(self, required=True, widget=None, label=None, initial=None, - help_text=None, error_messages=None, show_hidden_initial=False): + help_text=None, error_messages=None, show_hidden_initial=False, + validators=[]): # required -- Boolean that specifies whether the field is required. # True by default. # widget -- A Widget class, or instance of a Widget class, that should @@ -82,6 +87,7 @@ class Field(object): # help_text -- An optional string to use as "help text" for this Field. # show_hidden_initial -- Boolean that specifies if it is needed to render a # hidden widget with initial value after widget. + # validators -- List of addtional validators to use if label is not None: label = smart_unicode(label) self.required, self.label, self.initial = required, label, initial @@ -105,16 +111,39 @@ class Field(object): self.creation_counter = Field.creation_counter Field.creation_counter += 1 - def set_class_error_messages(messages, klass): - for base_class in klass.__bases__: - set_class_error_messages(messages, base_class) - messages.update(getattr(klass, 'default_error_messages', {})) - messages = {} - set_class_error_messages(messages, self.__class__) + for c in reversed(self.__class__.__mro__): + messages.update(getattr(c, 'default_error_messages', {})) messages.update(error_messages or {}) self.error_messages = messages + self.validators = self.default_validators + validators + + def to_python(self, value): + return value + + def validate(self, value): + if value in validators.EMPTY_VALUES and self.required: + raise ValidationError(self.error_messages['required']) + + def run_validators(self, value): + if value in validators.EMPTY_VALUES: + return + errors = [] + for v in self.validators: + try: + v(value) + except ValidationError, e: + if hasattr(e, 'code') and e.code in self.error_messages: + message = self.error_messages[e.code] + if e.params: + message = message % e.params + errors.append(message) + else: + errors.extend(e.messages) + if errors: + raise ValidationError(errors) + def clean(self, value): """ Validates the given value and returns its "cleaned" value as an @@ -122,8 +151,9 @@ class Field(object): Raises ValidationError for any errors. """ - if self.required and value in EMPTY_VALUES: - raise ValidationError(self.error_messages['required']) + value = self.to_python(value) + self.validate(value) + self.run_validators(value) return value def widget_attrs(self, widget): @@ -141,27 +171,19 @@ class Field(object): return result class CharField(Field): - default_error_messages = { - 'max_length': _(u'Ensure this value has at most %(max)d characters (it has %(length)d).'), - 'min_length': _(u'Ensure this value has at least %(min)d characters (it has %(length)d).'), - } - def __init__(self, max_length=None, min_length=None, *args, **kwargs): self.max_length, self.min_length = max_length, min_length super(CharField, self).__init__(*args, **kwargs) + if min_length is not None: + self.validators.append(validators.MinLengthValidator(min_length)) + if max_length is not None: + self.validators.append(validators.MaxLengthValidator(max_length)) - def clean(self, value): - "Validates max_length and min_length. Returns a Unicode object." - super(CharField, self).clean(value) - if value in EMPTY_VALUES: + def to_python(self, value): + "Returns a Unicode object." + if value in validators.EMPTY_VALUES: return u'' - value = smart_unicode(value) - value_length = len(value) - if self.max_length is not None and value_length > self.max_length: - raise ValidationError(self.error_messages['max_length'] % {'max': self.max_length, 'length': value_length}) - if self.min_length is not None and value_length < self.min_length: - raise ValidationError(self.error_messages['min_length'] % {'min': self.min_length, 'length': value_length}) - return value + return smart_unicode(value) def widget_attrs(self, widget): if self.max_length is not None and isinstance(widget, (TextInput, PasswordInput)): @@ -171,87 +193,82 @@ class CharField(Field): class IntegerField(Field): default_error_messages = { 'invalid': _(u'Enter a whole number.'), - 'max_value': _(u'Ensure this value is less than or equal to %s.'), - 'min_value': _(u'Ensure this value is greater than or equal to %s.'), + 'max_value': _(u'Ensure this value is less than or equal to %(limit_value)s.'), + 'min_value': _(u'Ensure this value is greater than or equal to %(limit_value)s.'), } def __init__(self, max_value=None, min_value=None, *args, **kwargs): - self.max_value, self.min_value = max_value, min_value super(IntegerField, self).__init__(*args, **kwargs) - def clean(self, value): + if max_value is not None: + self.validators.append(validators.MaxValueValidator(max_value)) + if min_value is not None: + self.validators.append(validators.MinValueValidator(min_value)) + + def to_python(self, value): """ Validates that int() can be called on the input. Returns the result of int(). Returns None for empty values. """ - super(IntegerField, self).clean(value) - if value in EMPTY_VALUES: + value = super(IntegerField, self).to_python(value) + if value in validators.EMPTY_VALUES: return None + try: value = int(str(value)) except (ValueError, TypeError): raise ValidationError(self.error_messages['invalid']) - if self.max_value is not None and value > self.max_value: - raise ValidationError(self.error_messages['max_value'] % self.max_value) - if self.min_value is not None and value < self.min_value: - raise ValidationError(self.error_messages['min_value'] % self.min_value) return value -class FloatField(Field): +class FloatField(IntegerField): default_error_messages = { 'invalid': _(u'Enter a number.'), - 'max_value': _(u'Ensure this value is less than or equal to %s.'), - 'min_value': _(u'Ensure this value is greater than or equal to %s.'), } - def __init__(self, max_value=None, min_value=None, *args, **kwargs): - self.max_value, self.min_value = max_value, min_value - Field.__init__(self, *args, **kwargs) - - def clean(self, value): + def to_python(self, value): """ - Validates that float() can be called on the input. Returns a float. - Returns None for empty values. + Validates that float() can be called on the input. Returns the result + of float(). Returns None for empty values. """ - super(FloatField, self).clean(value) - if not self.required and value in EMPTY_VALUES: + value = super(IntegerField, self).to_python(value) + if value in validators.EMPTY_VALUES: return None + try: # We always accept dot as decimal separator if isinstance(value, str) or isinstance(value, unicode): value = float(value.replace(get_format('DECIMAL_SEPARATOR'), '.')) except (ValueError, TypeError): raise ValidationError(self.error_messages['invalid']) - if self.max_value is not None and value > self.max_value: - raise ValidationError(self.error_messages['max_value'] % self.max_value) - if self.min_value is not None and value < self.min_value: - raise ValidationError(self.error_messages['min_value'] % self.min_value) return value class DecimalField(Field): default_error_messages = { 'invalid': _(u'Enter a number.'), - 'max_value': _(u'Ensure this value is less than or equal to %s.'), - 'min_value': _(u'Ensure this value is greater than or equal to %s.'), + 'max_value': _(u'Ensure this value is less than or equal to %(limit_value)s.'), + 'min_value': _(u'Ensure this value is greater than or equal to %(limit_value)s.'), 'max_digits': _('Ensure that there are no more than %s digits in total.'), 'max_decimal_places': _('Ensure that there are no more than %s decimal places.'), 'max_whole_digits': _('Ensure that there are no more than %s digits before the decimal point.') } def __init__(self, max_value=None, min_value=None, max_digits=None, decimal_places=None, *args, **kwargs): - self.max_value, self.min_value = max_value, min_value self.max_digits, self.decimal_places = max_digits, decimal_places Field.__init__(self, *args, **kwargs) - def clean(self, value): + if max_value is not None: + self.validators.append(validators.MaxValueValidator(max_value)) + if min_value is not None: + self.validators.append(validators.MinValueValidator(min_value)) + + def to_python(self, value): """ Validates that the input is a decimal number. Returns a Decimal instance. Returns None for empty values. Ensures that there are no more than max_digits in the number, and no more than decimal_places digits after the decimal point. """ - super(DecimalField, self).clean(value) - if not self.required and value in EMPTY_VALUES: + if value in validators.EMPTY_VALUES: return None value = smart_str(value).strip() try: @@ -260,7 +277,12 @@ class DecimalField(Field): value = Decimal(value.replace(get_format('DECIMAL_SEPARATOR'), '.')) except DecimalException: raise ValidationError(self.error_messages['invalid']) + return value + def validate(self, value): + super(DecimalField, self).validate(value) + if value in validators.EMPTY_VALUES: + return sign, digittuple, exponent = value.as_tuple() decimals = abs(exponent) # digittuple doesn't include any leading zeros. @@ -273,10 +295,6 @@ class DecimalField(Field): digits = decimals whole_digits = digits - decimals - if self.max_value is not None and value > self.max_value: - raise ValidationError(self.error_messages['max_value'] % self.max_value) - if self.min_value is not None and value < self.min_value: - raise ValidationError(self.error_messages['min_value'] % self.min_value) if self.max_digits is not None and digits > self.max_digits: raise ValidationError(self.error_messages['max_digits'] % self.max_digits) if self.decimal_places is not None and decimals > self.decimal_places: @@ -295,13 +313,12 @@ class DateField(Field): super(DateField, self).__init__(*args, **kwargs) self.input_formats = input_formats - def clean(self, value): + def to_python(self, value): """ Validates that the input can be converted to a date. Returns a Python datetime.date object. """ - super(DateField, self).clean(value) - if value in EMPTY_VALUES: + if value in validators.EMPTY_VALUES: return None if isinstance(value, datetime.datetime): return value.date() @@ -324,13 +341,12 @@ class TimeField(Field): super(TimeField, self).__init__(*args, **kwargs) self.input_formats = input_formats - def clean(self, value): + def to_python(self, value): """ Validates that the input can be converted to a time. Returns a Python datetime.time object. """ - super(TimeField, self).clean(value) - if value in EMPTY_VALUES: + if value in validators.EMPTY_VALUES: return None if isinstance(value, datetime.time): return value @@ -351,13 +367,12 @@ class DateTimeField(Field): super(DateTimeField, self).__init__(*args, **kwargs) self.input_formats = input_formats - def clean(self, value): + def to_python(self, value): """ Validates that the input can be converted to a datetime. Returns a Python datetime.datetime object. """ - super(DateTimeField, self).clean(value) - if value in EMPTY_VALUES: + if value in validators.EMPTY_VALUES: return None if isinstance(value, datetime.datetime): return value @@ -392,40 +407,13 @@ class RegexField(CharField): if isinstance(regex, basestring): regex = re.compile(regex) self.regex = regex + self.validators.append(validators.RegexValidator(regex=regex)) - def clean(self, value): - """ - Validates that the input matches the regular expression. Returns a - Unicode object. - """ - value = super(RegexField, self).clean(value) - if value == u'': - return value - if not self.regex.search(value): - raise ValidationError(self.error_messages['invalid']) - return value - -email_re = re.compile( - r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom - r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*"' # quoted-string - r')@(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?$', re.IGNORECASE) # domain - -class EmailField(RegexField): +class EmailField(CharField): default_error_messages = { 'invalid': _(u'Enter a valid e-mail address.'), } - - def __init__(self, max_length=None, min_length=None, *args, **kwargs): - RegexField.__init__(self, email_re, max_length, min_length, *args, - **kwargs) - -try: - from django.conf import settings - URL_VALIDATOR_USER_AGENT = settings.URL_VALIDATOR_USER_AGENT -except ImportError: - # It's OK if Django settings aren't configured. - URL_VALIDATOR_USER_AGENT = 'Django (http://www.djangoproject.com/)' - + default_validators = [validators.validate_email] class FileField(Field): widget = FileInput @@ -440,12 +428,9 @@ class FileField(Field): self.max_length = kwargs.pop('max_length', None) super(FileField, self).__init__(*args, **kwargs) - def clean(self, data, initial=None): - super(FileField, self).clean(initial or data) - if not self.required and data in EMPTY_VALUES: + def to_python(self, data): + if data in validators.EMPTY_VALUES: return None - elif not data and initial: - return initial # UploadedFile objects should have name and size attributes. try: @@ -464,21 +449,24 @@ class FileField(Field): return data + def clean(self, data, initial=None): + if not data and initial: + return initial + return super(FileField, self).clean(data) + class ImageField(FileField): default_error_messages = { 'invalid_image': _(u"Upload a valid image. The file you uploaded was either not an image or a corrupted image."), } - def clean(self, data, initial=None): + def to_python(self, data): """ Checks that the file-upload field data contains a valid image (GIF, JPG, PNG, possibly others -- whatever the Python Imaging Library supports). """ - f = super(ImageField, self).clean(data, initial) + f = super(ImageField, self).to_python(data) if f is None: return None - elif not data and initial: - return initial from PIL import Image # We need to get a file object for PIL. We might have a path or we might @@ -517,59 +505,34 @@ class ImageField(FileField): f.seek(0) return f -url_re = re.compile( - r'^https?://' # http:// or https:// - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' #domain... - r'localhost|' #localhost... - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip - r'(?::\d+)?' # optional port - r'(?:/?|/\S+)$', re.IGNORECASE) - -class URLField(RegexField): +class URLField(CharField): default_error_messages = { 'invalid': _(u'Enter a valid URL.'), 'invalid_link': _(u'This URL appears to be a broken link.'), } def __init__(self, max_length=None, min_length=None, verify_exists=False, - validator_user_agent=URL_VALIDATOR_USER_AGENT, *args, **kwargs): - super(URLField, self).__init__(url_re, max_length, min_length, *args, + validator_user_agent=validators.URL_VALIDATOR_USER_AGENT, *args, **kwargs): + super(URLField, self).__init__(max_length, min_length, *args, **kwargs) - self.verify_exists = verify_exists - self.user_agent = validator_user_agent + self.validators.append(validators.URLValidator(verify_exists=verify_exists, validator_user_agent=validator_user_agent)) - def clean(self, value): - # If no URL scheme given, assume http:// - if value and '://' not in value: - value = u'http://%s' % value - # If no URL path given, assume / - if value and not urlparse.urlsplit(value)[2]: - value += '/' - value = super(URLField, self).clean(value) - if value == u'': - return value - if self.verify_exists: - import urllib2 - headers = { - "Accept": "text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5", - "Accept-Language": "en-us,en;q=0.5", - "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.7", - "Connection": "close", - "User-Agent": self.user_agent, - } - try: - req = urllib2.Request(value, None, headers) - u = urllib2.urlopen(req) - except ValueError: - raise ValidationError(self.error_messages['invalid']) - except: # urllib2.URLError, httplib.InvalidURL, etc. - raise ValidationError(self.error_messages['invalid_link']) - return value + def to_python(self, value): + if value: + if '://' not in value: + # If no URL scheme given, assume http:// + value = u'http://%s' % value + url_fields = list(urlparse.urlsplit(value)) + if not url_fields[2]: + # the path portion may need to be added before query params + url_fields[2] = '/' + value = urlparse.urlunsplit(url_fields) + return super(URLField, self).to_python(value) class BooleanField(Field): widget = CheckboxInput - def clean(self, value): + def to_python(self, value): """Returns a Python boolean object.""" # Explicitly check for the string 'False', which is what a hidden field # will submit for False. Also check for '0', since this is what @@ -579,7 +542,7 @@ class BooleanField(Field): value = False else: value = bool(value) - super(BooleanField, self).clean(value) + value = super(BooleanField, self).to_python(value) if not value and self.required: raise ValidationError(self.error_messages['required']) return value @@ -591,7 +554,7 @@ class NullBooleanField(BooleanField): """ widget = NullBooleanSelect - def clean(self, value): + def to_python(self, value): """ Explicitly checks for the string 'True' and 'False', which is what a hidden field will submit for True and False, and for '1' and '0', which @@ -605,6 +568,9 @@ class NullBooleanField(BooleanField): else: return None + def validate(self, value): + pass + class ChoiceField(Field): widget = Select default_error_messages = { @@ -613,8 +579,8 @@ class ChoiceField(Field): def __init__(self, choices=(), required=True, widget=None, label=None, initial=None, help_text=None, *args, **kwargs): - super(ChoiceField, self).__init__(required, widget, label, initial, - help_text, *args, **kwargs) + super(ChoiceField, self).__init__(required=required, widget=widget, label=label, + initial=initial, help_text=help_text, *args, **kwargs) self.choices = choices def _get_choices(self): @@ -628,19 +594,19 @@ class ChoiceField(Field): choices = property(_get_choices, _set_choices) - def clean(self, value): + def to_python(self, value): + "Returns a Unicode object." + if value in validators.EMPTY_VALUES: + return u'' + return smart_unicode(value) + + def validate(self, value): """ Validates that the input is in self.choices. """ - value = super(ChoiceField, self).clean(value) - if value in EMPTY_VALUES: - value = u'' - value = smart_unicode(value) - if value == u'': - return value - if not self.valid_value(value): + super(ChoiceField, self).validate(value) + if value and not self.valid_value(value): raise ValidationError(self.error_messages['invalid_choice'] % {'value': value}) - return value def valid_value(self, value): "Check to see if the provided value is a valid choice" @@ -661,27 +627,24 @@ class TypedChoiceField(ChoiceField): self.empty_value = kwargs.pop('empty_value', '') super(TypedChoiceField, self).__init__(*args, **kwargs) - def clean(self, value): + def to_python(self, value): """ Validate that the value is in self.choices and can be coerced to the right type. """ - value = super(TypedChoiceField, self).clean(value) - if value == self.empty_value or value in EMPTY_VALUES: + value = super(TypedChoiceField, self).to_python(value) + super(TypedChoiceField, self).validate(value) + if value == self.empty_value or value in validators.EMPTY_VALUES: return self.empty_value - - # Hack alert: This field is purpose-made to use with Field.to_python as - # a coercion function so that ModelForms with choices work. However, - # Django's Field.to_python raises - # django.core.exceptions.ValidationError, which is a *different* - # exception than django.forms.util.ValidationError. So we need to catch - # both. try: value = self.coerce(value) - except (ValueError, TypeError, django.core.exceptions.ValidationError): + except (ValueError, TypeError, ValidationError): raise ValidationError(self.error_messages['invalid_choice'] % {'value': value}) return value + def validate(self, value): + pass + class MultipleChoiceField(ChoiceField): hidden_widget = MultipleHiddenInput widget = SelectMultiple @@ -690,22 +653,23 @@ class MultipleChoiceField(ChoiceField): 'invalid_list': _(u'Enter a list of values.'), } - def clean(self, value): + def to_python(self, value): + if not value: + return [] + elif not isinstance(value, (list, tuple)): + raise ValidationError(self.error_messages['invalid_list']) + return [smart_unicode(val) for val in value] + + def validate(self, value): """ Validates that the input is a list or tuple. """ if self.required and not value: raise ValidationError(self.error_messages['required']) - elif not self.required and not value: - return [] - if not isinstance(value, (list, tuple)): - raise ValidationError(self.error_messages['invalid_list']) - new_value = [smart_unicode(val) for val in value] # Validate that each value in the value list is in self.choices. - for val in new_value: + for val in value: if not self.valid_value(val): raise ValidationError(self.error_messages['invalid_choice'] % {'value': val}) - return new_value class ComboField(Field): """ @@ -760,6 +724,9 @@ class MultiValueField(Field): f.required = False self.fields = fields + def validate(self, value): + pass + def clean(self, value): """ Validates every value in the given list. A value is validated against @@ -772,7 +739,7 @@ class MultiValueField(Field): clean_data = [] errors = ErrorList() if not value or isinstance(value, (list, tuple)): - if not value or not [v for v in value if v not in EMPTY_VALUES]: + if not value or not [v for v in value if v not in validators.EMPTY_VALUES]: if self.required: raise ValidationError(self.error_messages['required']) else: @@ -784,7 +751,7 @@ class MultiValueField(Field): field_value = value[i] except IndexError: field_value = None - if self.required and field_value in EMPTY_VALUES: + if self.required and field_value in validators.EMPTY_VALUES: raise ValidationError(self.error_messages['required']) try: clean_data.append(field.clean(field_value)) @@ -795,7 +762,10 @@ class MultiValueField(Field): errors.extend(e.messages) if errors: raise ValidationError(errors) - return self.compress(clean_data) + + out = self.compress(clean_data) + self.validate(out) + return out def compress(self, data_list): """ @@ -864,30 +834,24 @@ class SplitDateTimeField(MultiValueField): if data_list: # Raise a validation error if time or date is empty # (possible if SplitDateTimeField has required=False). - if data_list[0] in EMPTY_VALUES: + if data_list[0] in validators.EMPTY_VALUES: raise ValidationError(self.error_messages['invalid_date']) - if data_list[1] in EMPTY_VALUES: + if data_list[1] in validators.EMPTY_VALUES: raise ValidationError(self.error_messages['invalid_time']) return datetime.datetime.combine(*data_list) return None -ipv4_re = re.compile(r'^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3}$') -class IPAddressField(RegexField): +class IPAddressField(CharField): default_error_messages = { 'invalid': _(u'Enter a valid IPv4 address.'), } + default_validators = [validators.validate_ipv4_address] - def __init__(self, *args, **kwargs): - super(IPAddressField, self).__init__(ipv4_re, *args, **kwargs) -slug_re = re.compile(r'^[-\w]+$') - -class SlugField(RegexField): +class SlugField(CharField): default_error_messages = { 'invalid': _(u"Enter a valid 'slug' consisting of letters, numbers," u" underscores or hyphens."), } - - def __init__(self, *args, **kwargs): - super(SlugField, self).__init__(slug_re, *args, **kwargs) + default_validators = [validators.validate_slug] diff --git a/django/forms/forms.py b/django/forms/forms.py index 7f6fa51287..d484300a0d 100644 --- a/django/forms/forms.py +++ b/django/forms/forms.py @@ -2,6 +2,7 @@ Form classes """ +from django.core.exceptions import ValidationError from django.utils.copycompat import deepcopy from django.utils.datastructures import SortedDict from django.utils.html import conditional_escape @@ -10,7 +11,7 @@ from django.utils.safestring import mark_safe from fields import Field, FileField from widgets import Media, media_property, TextInput, Textarea -from util import flatatt, ErrorDict, ErrorList, ValidationError +from util import flatatt, ErrorDict, ErrorList __all__ = ('BaseForm', 'Form') diff --git a/django/forms/formsets.py b/django/forms/formsets.py index bb8e3107e1..d5101c762e 100644 --- a/django/forms/formsets.py +++ b/django/forms/formsets.py @@ -1,10 +1,11 @@ from forms import Form +from django.core.exceptions import ValidationError from django.utils.encoding import StrAndUnicode from django.utils.safestring import mark_safe from django.utils.translation import ugettext as _ from fields import IntegerField, BooleanField from widgets import Media, HiddenInput -from util import ErrorList, ErrorDict, ValidationError +from util import ErrorList __all__ = ('BaseFormSet', 'all_valid') diff --git a/django/forms/models.py b/django/forms/models.py index 1c5f446c2b..ff20c936cb 100644 --- a/django/forms/models.py +++ b/django/forms/models.py @@ -9,10 +9,12 @@ from django.utils.datastructures import SortedDict from django.utils.text import get_text_list, capfirst from django.utils.translation import ugettext_lazy as _, ugettext -from util import ValidationError, ErrorList -from forms import BaseForm, get_declared_fields, NON_FIELD_ERRORS -from fields import Field, ChoiceField, IntegerField, EMPTY_VALUES -from widgets import Select, SelectMultiple, HiddenInput, MultipleHiddenInput +from django.core.exceptions import ValidationError, NON_FIELD_ERRORS, UnresolvableValidationError +from django.core.validators import EMPTY_VALUES +from util import ErrorList +from forms import BaseForm, get_declared_fields +from fields import Field, ChoiceField +from widgets import SelectMultiple, HiddenInput, MultipleHiddenInput from widgets import media_property from formsets import BaseFormSet, formset_factory, DELETION_FIELD_NAME @@ -27,20 +29,15 @@ __all__ = ( 'ModelMultipleChoiceField', ) - -def save_instance(form, instance, fields=None, fail_message='saved', - commit=True, exclude=None): +def construct_instance(form, instance, fields=None, exclude=None): """ - Saves bound Form ``form``'s cleaned_data into model instance ``instance``. - - If commit=True, then the changes to ``instance`` will be saved to the - database. Returns ``instance``. + Constructs and returns a model instance from the bound ``form``'s + ``cleaned_data``, but does not save the returned instance to the + database. """ from django.db import models opts = instance._meta - if form.errors: - raise ValueError("The %s could not be %s because the data didn't" - " validate." % (opts.object_name, fail_message)) + cleaned_data = form.cleaned_data file_field_list = [] for f in opts.fields: @@ -65,9 +62,28 @@ def save_instance(form, instance, fields=None, fail_message='saved', for f in file_field_list: f.save_form_data(instance, cleaned_data[f.name]) + return instance + +def save_instance(form, instance, fields=None, fail_message='saved', + commit=True, exclude=None, construct=True): + """ + Saves bound Form ``form``'s cleaned_data into model instance ``instance``. + + If commit=True, then the changes to ``instance`` will be saved to the + database. Returns ``instance``. + + If construct=False, assume ``instance`` has already been constructed and + just needs to be saved. + """ + if construct: + instance = construct_instance(form, instance, fields, exclude) + opts = instance._meta + if form.errors: + raise ValueError("The %s could not be %s because the data didn't" + " validate." % (opts.object_name, fail_message)) + # Wrap up the saving of m2m data as a function. def save_m2m(): - opts = instance._meta cleaned_data = form.cleaned_data for f in opts.many_to_many: if fields and f.name not in fields: @@ -120,7 +136,7 @@ def model_to_dict(instance, fields=None, exclude=None): the ``fields`` argument. """ # avoid a circular import - from django.db.models.fields.related import ManyToManyField, OneToOneField + from django.db.models.fields.related import ManyToManyField opts = instance._meta data = {} for f in opts.fields + opts.many_to_many: @@ -218,8 +234,10 @@ class BaseModelForm(BaseForm): # if we didn't get an instance, instantiate a new one self.instance = opts.model() object_data = {} + self.instance._adding = True else: self.instance = instance + self.instance._adding = False object_data = model_to_dict(instance, opts.fields, opts.exclude) # if initial was provided, it should override the values from instance if initial is not None: @@ -228,166 +246,32 @@ class BaseModelForm(BaseForm): error_class, label_suffix, empty_permitted) def clean(self): - self.validate_unique() + opts = self._meta + self.instance = construct_instance(self, self.instance, opts.fields, opts.exclude) + try: + self.instance.full_validate(exclude=self._errors.keys()) + except ValidationError, e: + for k, v in e.message_dict.items(): + if k != NON_FIELD_ERRORS: + self._errors.setdefault(k, ErrorList()).extend(v) + + # Remove the data from the cleaned_data dict since it was invalid + if k in self.cleaned_data: + del self.cleaned_data[k] + + if NON_FIELD_ERRORS in e.message_dict: + raise ValidationError(e.message_dict[NON_FIELD_ERRORS]) + + # If model validation threw errors for fields that aren't on the + # form, the the errors cannot be corrected by the user. Displaying + # those errors would be pointless, so raise another type of + # exception that *won't* be caught and displayed by the form. + if set(e.message_dict.keys()) - set(self.fields.keys() + [NON_FIELD_ERRORS]): + raise UnresolvableValidationError(e.message_dict) + + return self.cleaned_data - def validate_unique(self): - unique_checks, date_checks = self._get_unique_checks() - form_errors = [] - bad_fields = set() - - field_errors, global_errors = self._perform_unique_checks(unique_checks) - bad_fields.union(field_errors) - form_errors.extend(global_errors) - - field_errors, global_errors = self._perform_date_checks(date_checks) - bad_fields.union(field_errors) - form_errors.extend(global_errors) - - for field_name in bad_fields: - del self.cleaned_data[field_name] - if form_errors: - # Raise the unique together errors since they are considered - # form-wide. - raise ValidationError(form_errors) - - def _get_unique_checks(self): - from django.db.models.fields import FieldDoesNotExist, Field as ModelField - - # Gather a list of checks to perform. We only perform unique checks - # for fields present and not None in cleaned_data. Since this is a - # ModelForm, some fields may have been excluded; we can't perform a unique - # check on a form that is missing fields involved in that check. It also does - # not make sense to check data that didn't validate, and since NULL does not - # equal NULL in SQL we should not do any unique checking for NULL values. - unique_checks = [] - # these are checks for the unique_for_ - date_checks = [] - for check in self.instance._meta.unique_together[:]: - fields_on_form = [field for field in check if self.cleaned_data.get(field) is not None] - if len(fields_on_form) == len(check): - unique_checks.append(check) - - # Gather a list of checks for fields declared as unique and add them to - # the list of checks. Again, skip empty fields and any that did not validate. - for name in self.fields: - try: - f = self.instance._meta.get_field_by_name(name)[0] - except FieldDoesNotExist: - # This is an extra field that's not on the ModelForm, ignore it - continue - if not isinstance(f, ModelField): - # This is an extra field that happens to have a name that matches, - # for example, a related object accessor for this model. So - # get_field_by_name found it, but it is not a Field so do not proceed - # to use it as if it were. - continue - if self.cleaned_data.get(name) is None: - continue - if f.unique: - unique_checks.append((name,)) - if f.unique_for_date and self.cleaned_data.get(f.unique_for_date) is not None: - date_checks.append(('date', name, f.unique_for_date)) - if f.unique_for_year and self.cleaned_data.get(f.unique_for_year) is not None: - date_checks.append(('year', name, f.unique_for_year)) - if f.unique_for_month and self.cleaned_data.get(f.unique_for_month) is not None: - date_checks.append(('month', name, f.unique_for_month)) - return unique_checks, date_checks - - - def _perform_unique_checks(self, unique_checks): - bad_fields = set() - form_errors = [] - - for unique_check in unique_checks: - # Try to look up an existing object with the same values as this - # object's values for all the unique field. - - lookup_kwargs = {} - for field_name in unique_check: - lookup_value = self.cleaned_data[field_name] - # ModelChoiceField will return an object instance rather than - # a raw primary key value, so convert it to a pk value before - # using it in a lookup. - if isinstance(self.fields[field_name], ModelChoiceField): - lookup_value = lookup_value.pk - lookup_kwargs[str(field_name)] = lookup_value - - qs = self.instance.__class__._default_manager.filter(**lookup_kwargs) - - # Exclude the current object from the query if we are editing an - # instance (as opposed to creating a new one) - if self.instance.pk is not None: - qs = qs.exclude(pk=self.instance.pk) - - if qs.exists(): - if len(unique_check) == 1: - self._errors[unique_check[0]] = ErrorList([self.unique_error_message(unique_check)]) - else: - form_errors.append(self.unique_error_message(unique_check)) - - # Mark these fields as needing to be removed from cleaned data - # later. - for field_name in unique_check: - bad_fields.add(field_name) - return bad_fields, form_errors - - def _perform_date_checks(self, date_checks): - bad_fields = set() - for lookup_type, field, unique_for in date_checks: - lookup_kwargs = {} - # there's a ticket to add a date lookup, we can remove this special - # case if that makes it's way in - if lookup_type == 'date': - date = self.cleaned_data[unique_for] - lookup_kwargs['%s__day' % unique_for] = date.day - lookup_kwargs['%s__month' % unique_for] = date.month - lookup_kwargs['%s__year' % unique_for] = date.year - else: - lookup_kwargs['%s__%s' % (unique_for, lookup_type)] = getattr(self.cleaned_data[unique_for], lookup_type) - lookup_kwargs[field] = self.cleaned_data[field] - - qs = self.instance.__class__._default_manager.filter(**lookup_kwargs) - # Exclude the current object from the query if we are editing an - # instance (as opposed to creating a new one) - if self.instance.pk is not None: - qs = qs.exclude(pk=self.instance.pk) - - if qs.exists(): - self._errors[field] = ErrorList([ - self.date_error_message(lookup_type, field, unique_for) - ]) - bad_fields.add(field) - return bad_fields, [] - - def date_error_message(self, lookup_type, field, unique_for): - return _(u"%(field_name)s must be unique for %(date_field)s %(lookup)s.") % { - 'field_name': unicode(self.fields[field].label), - 'date_field': unicode(self.fields[unique_for].label), - 'lookup': lookup_type, - } - - def unique_error_message(self, unique_check): - model_name = capfirst(self.instance._meta.verbose_name) - - # A unique field - if len(unique_check) == 1: - field_name = unique_check[0] - field_label = self.fields[field_name].label - # Insert the error into the error dict, very sneaky - return _(u"%(model_name)s with this %(field_label)s already exists.") % { - 'model_name': unicode(model_name), - 'field_label': unicode(field_label) - } - # unique_together - else: - field_labels = [self.fields[field_name].label for field_name in unique_check] - field_labels = get_text_list(field_labels, _('and')) - return _(u"%(model_name)s with this %(field_label)s already exists.") % { - 'model_name': unicode(model_name), - 'field_label': unicode(field_labels) - } - def save(self, commit=True): """ Saves this ``form``'s cleaned_data into model instance @@ -401,7 +285,7 @@ class BaseModelForm(BaseForm): else: fail_message = 'changed' return save_instance(self, self.instance, self._meta.fields, - fail_message, commit, exclude=self._meta.exclude) + fail_message, commit, construct=False) save.alters_data = True @@ -530,7 +414,7 @@ class BaseModelFormSet(BaseFormSet): break else: return - unique_checks, date_checks = form._get_unique_checks() + unique_checks, date_checks = form.instance._get_unique_checks() errors = [] # Do each of the unique checks (unique and unique_together) for unique_check in unique_checks: @@ -743,6 +627,9 @@ class BaseInlineFormSet(BaseModelFormSet): # Remove the foreign key from the form's data form.data[form.add_prefix(self.fk.name)] = None + + # Set the fk value here so that the form can do it's validation. + setattr(form.instance, self.fk.get_attname(), self.instance.pk) return form #@classmethod diff --git a/django/forms/util.py b/django/forms/util.py index b9b88a61e6..1a1d823495 100644 --- a/django/forms/util.py +++ b/django/forms/util.py @@ -1,7 +1,11 @@ from django.utils.html import conditional_escape -from django.utils.encoding import smart_unicode, StrAndUnicode, force_unicode +from django.utils.encoding import StrAndUnicode, force_unicode from django.utils.safestring import mark_safe +# Import ValidationError so that it can be imported from this +# module to maintain backwards compatibility. +from django.core.exceptions import ValidationError + def flatatt(attrs): """ Convert a dictionary of attributes to a single string. @@ -48,21 +52,3 @@ class ErrorList(list, StrAndUnicode): def __repr__(self): return repr([force_unicode(e) for e in self]) -class ValidationError(Exception): - def __init__(self, message): - """ - ValidationError can be passed any object that can be printed (usually - a string) or a list of objects. - """ - if isinstance(message, list): - self.messages = ErrorList([smart_unicode(msg) for msg in message]) - else: - message = smart_unicode(message) - self.messages = ErrorList([message]) - - def __str__(self): - # This is needed because, without a __str__(), printing an exception - # instance would result in this: - # AttributeError: ValidationError instance has no attribute 'args' - # See http://www.python.org/doc/current/tut/node10.html#handling - return repr(self.messages) diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 4bb6a7c444..0d40e5fd5f 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -257,6 +257,17 @@ And here is a custom error message:: In the `built-in Field classes`_ section below, each ``Field`` defines the error message keys it uses. +``validators`` +~~~~~~~~~~~~~~ +.. versionadded:: 1.2 + +.. attribute:: Field.validators + +The ``validators`` argument lets you provide a list of validation functions +for this field. + +See the :ref:`validators documentation ` for more information. + Built-in ``Field`` classes -------------------------- diff --git a/docs/ref/forms/validation.txt b/docs/ref/forms/validation.txt index 6f5041f699..b6642d5253 100644 --- a/docs/ref/forms/validation.txt +++ b/docs/ref/forms/validation.txt @@ -3,6 +3,8 @@ Form and field validation ========================= +.. versionchanged:: 1.2 + Form validation happens when the data is cleaned. If you want to customize this process, there are various places you can change, each one serving a different purpose. Three types of cleaning methods are run during form @@ -20,13 +22,38 @@ If you detect multiple errors during a cleaning method and wish to signal all of them to the form submitter, it is possible to pass a list of errors to the ``ValidationError`` constructor. -The three types of cleaning methods are: +Most validation can be done using `validators`_ - simple helpers that can be +reused easily. Validators are simple functions (or callables) that take a single +argument and raise ``ValidationError`` on invalid input. Validators are run +after the field's ``to_python`` and ``validate`` methods have been called. - * The ``clean()`` method on a Field subclass. This is responsible - for cleaning the data in a way that is generic for that type of field. - For example, a FloatField will turn the data into a Python ``float`` or - raise a ``ValidationError``. This method returns the clean data, which - is then inserted into the ``cleaned_data`` dictionary of the form. +Validation of a Form is split into several steps, which can be customized or +overridden: + + * The ``to_python()`` method on a Field is the first step in every + validation. It coerces the value to correct datatype and raises + ``ValidationError`` if that is not possible. This method accepts the raw + value from the widget and returns the converted value. For example, a + FloatField will turn the data into a Python ``float`` or raise a + ``ValidationError``. + + * The ``validate()`` method on a Field handles field-specific validation + that is not suitable for a validator, It takes a value that has been + coerced to correct datatype and raises ``ValidationError`` on any error. + This method does not return anything and shouldn't alter the value. You + should override it to handle validation logic that you can't or don't + want to put in a validator. + + * The ``run_validators()`` method on a Field runs all of the field's + validators and aggregates all the errors into a single + ``ValidationError``. You shouldn't need to override this method. + + * The ``clean()`` method on a Field subclass. This is responsible for + running ``to_python``, ``validate`` and ``run_validators`` in the correct + order and propagating their errors. If, at any time, any of the methods + raise ``ValidationError``, the validation stops and that error is raised. + This method returns the clean data, which is then inserted into the + ``cleaned_data`` dictionary of the form. * The ``clean_()`` method in a form subclass -- where ```` is replaced with the name of the form field attribute. @@ -141,35 +168,68 @@ Since it can sometimes be easier to put things into place by seeing each feature in use, here are a series of small examples that use each of the previous features. +.. _validators: + +Using validators +~~~~~~~~~~~~~~~~ +.. versionadded:: 1.2 + +Django's form (and model) fields support use of simple utility functions and +classes known as validators. These can passed to a field's constructor, via +the field's ``validators`` argument, or defined on the Field class itself with +the ``default_validators`` attribute. + +Simple validators can be used to validate values inside the field, let's have +a look at Django's ``EmailField``:: + + class EmailField(CharField): + default_error_messages = { + 'invalid': _(u'Enter a valid e-mail address.'), + } + default_validators = [validators.validate_email] + +As you can see, ``EmailField`` is just a ``CharField`` with customized error +message and a validator that validates e-mail addresses. This can also be done +on field definition so:: + + email = forms.EmailField() + +is equivalent to:: + + email = forms.CharField(validators=[validators.validate_email], + error_messages={'invalid': _(u'Enter a valid e-mail address.')}) + + Form field default cleaning ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Let's firstly create a custom form field that validates its input is a string -containing comma-separated e-mail addresses, with at least one address. We'll -keep it simple and assume e-mail validation is contained in a function called -``is_valid_email()``. The full class looks like this:: +containing comma-separated e-mail addresses. The full class looks like this:: from django import forms + from django.core.validators import validate_email class MultiEmailField(forms.Field): - def clean(self, value): - """ - Check that the field contains one or more comma-separated emails - and normalizes the data to a list of the email strings. - """ + def to_python(self, value): + "Normalize data to a list of strings." + + # Return an empty list if no input was given. if not value: - raise forms.ValidationError('Enter at least one e-mail address.') - emails = value.split(',') - for email in emails: - if not is_valid_email(email): - raise forms.ValidationError('%s is not a valid e-mail address.' % email) + return [] + return value.split(',') - # Always return the cleaned data. - return emails + def validate(self, value): + "Check if value consists only of valid emails." -Every form that uses this field will have this ``clean()`` method run before -anything else can be done with the field's data. This is cleaning that is -specific to this type of field, regardless of how it is subsequently used. + # Use the parent's handling of required fields, etc. + super(MultiEmailField, self).validate(value) + + for email in value: + validate_email(email) + +Every form that uses this field will have these methods run before anything +else can be done with the field's data. This is cleaning that is specific to +this type of field, regardless of how it is subsequently used. Let's create a simple ``ContactForm`` to demonstrate how you'd use this field:: @@ -183,7 +243,8 @@ field:: Simply use ``MultiEmailField`` like any other form field. When the ``is_valid()`` method is called on the form, the ``MultiEmailField.clean()`` -method will be run as part of the cleaning process. +method will be run as part of the cleaning process and it will, in turn, call +the custom ``to_python()`` and ``validate()`` methods. Cleaning a specific field attribute ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 3c1106a217..8612b742ef 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -196,6 +196,17 @@ callable it will be called every time a new object is created. If ``False``, the field will not be editable in the admin or via forms automatically generated from the model class. Default is ``True``. +``error_messages`` +------------------ + +.. versionadded:: 1.2 + +.. attribute:: Field.error_messages + +The ``error_messages`` argument lets you override the default messages that the +field will raise. Pass in a dictionary with keys matching the error messages you +want to override. + ``help_text`` ------------- @@ -284,6 +295,17 @@ underscores to spaces. See :ref:`Verbose field names `. .. _model-field-types: +``validators`` +------------------- + +.. versionadded:: 1.2 + +.. attribute:: Field.validators + +A list of validators to run for this field.See the :ref:`validators +documentation ` for more information. + + Field types =========== diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index 7a0606dafe..43f18338a1 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -27,6 +27,31 @@ The keyword arguments are simply the names of the fields you've defined on your model. Note that instantiating a model in no way touches your database; for that, you need to ``save()``. +Validating objects +================== + +.. versionadded:: 1.2 + +To validate your model, just call its ``full_validate()`` method: + +.. method:: Model.full_validate([exclude=[]]) + +The optional ``exclude`` argument can contain a list of field names that should +be omitted when validating. This method raises ``ValidationError`` containing a +message dict with errors from all fields. + +To add your own validation logic, override the supplied ``validate()`` method: + +.. method:: Model.validate() + +The ``validate()`` method on ``Model`` by default checks for uniqueness of +fields and group of fields that are declared to be unique so, remember to call +``self.validate_unique()`` or the superclasses ``validate`` method if you want +this validation to run. + +Any ``ValidationError`` raised in this method will be propagated in the +``message_dict`` under ``NON_FIELD_ERRORS``. + Saving objects ============== diff --git a/tests/modeltests/model_forms/models.py b/tests/modeltests/model_forms/models.py index c94e6d3e95..ba59f9ae36 100644 --- a/tests/modeltests/model_forms/models.py +++ b/tests/modeltests/model_forms/models.py @@ -68,7 +68,7 @@ class ImprovedArticleWithParentLink(models.Model): article = models.OneToOneField(Article, parent_link=True) class BetterWriter(Writer): - pass + score = models.IntegerField() class WriterProfile(models.Model): writer = models.OneToOneField(Writer, primary_key=True) @@ -555,6 +555,8 @@ inserted as 'initial' data in each Field. Hold down "Control", or "Command" on a Mac, to select more than one. >>> f = TestArticleForm({'headline': u'Test headline', 'slug': 'test-headline', 'pub_date': u'1984-02-06', 'writer': u'1', 'article': 'Hello.'}, instance=art) +>>> f.errors +{} >>> f.is_valid() True >>> test_art = f.save() @@ -967,10 +969,20 @@ ValidationError: [u'Select a valid choice. 4 is not one of the available choices >>> ImprovedArticleWithParentLinkForm.base_fields.keys() [] ->>> bw = BetterWriter(name=u'Joe Better') +>>> bw = BetterWriter(name=u'Joe Better', score=10) >>> bw.save() >>> sorted(model_to_dict(bw).keys()) -['id', 'name', 'writer_ptr'] +['id', 'name', 'score', 'writer_ptr'] + +>>> class BetterWriterForm(ModelForm): +... class Meta: +... model = BetterWriter +>>> form = BetterWriterForm({'name': 'Some Name', 'score': 12}) +>>> form.is_valid() +True +>>> bw2 = form.save() +>>> bw2.delete() + >>> class WriterProfileForm(ModelForm): ... class Meta: @@ -1102,16 +1114,6 @@ True >>> instance.delete() -# Test the non-required FileField - ->>> f = TextFileForm(data={'description': u'Assistance'}) ->>> f.fields['file'].required = False ->>> f.is_valid() -True ->>> instance = f.save() ->>> instance.file - - >>> f = TextFileForm(data={'description': u'Assistance'}, files={'file': SimpleUploadedFile('test3.txt', 'hello world')}, instance=instance) >>> f.is_valid() True @@ -1358,27 +1360,35 @@ __test__['API_TESTS'] += """ ... class Meta: ... model = CommaSeparatedInteger ->>> f = CommaSeparatedIntegerForm().fields['field'] ->>> f.clean('1,2,3') -u'1,2,3' ->>> f.clean('1a,2') -Traceback (most recent call last): -... -ValidationError: [u'Enter only digits separated by commas.'] ->>> f.clean(',,,,') -u',,,,' ->>> f.clean('1.2') -Traceback (most recent call last): -... -ValidationError: [u'Enter only digits separated by commas.'] ->>> f.clean('1,a,2') -Traceback (most recent call last): -... -ValidationError: [u'Enter only digits separated by commas.'] ->>> f.clean('1,,2') -u'1,,2' ->>> f.clean('1') -u'1' +>>> f = CommaSeparatedIntegerForm({'field': '1,2,3'}) +>>> f.is_valid() +True +>>> f.cleaned_data +{'field': u'1,2,3'} +>>> f = CommaSeparatedIntegerForm({'field': '1a,2'}) +>>> f.errors +{'field': [u'Enter only digits separated by commas.']} +>>> f = CommaSeparatedIntegerForm({'field': ',,,,'}) +>>> f.is_valid() +True +>>> f.cleaned_data +{'field': u',,,,'} +>>> f = CommaSeparatedIntegerForm({'field': '1.2'}) +>>> f.errors +{'field': [u'Enter only digits separated by commas.']} +>>> f = CommaSeparatedIntegerForm({'field': '1,a,2'}) +>>> f.errors +{'field': [u'Enter only digits separated by commas.']} +>>> f = CommaSeparatedIntegerForm({'field': '1,,2'}) +>>> f.is_valid() +True +>>> f.cleaned_data +{'field': u'1,,2'} +>>> f = CommaSeparatedIntegerForm({'field': '1'}) +>>> f.is_valid() +True +>>> f.cleaned_data +{'field': u'1'} # unique/unique_together validation @@ -1415,13 +1425,16 @@ False >>> form._errors {'__all__': [u'Price with this Price and Quantity already exists.']} +# This form is never valid because quantity is blank=False. >>> class PriceForm(ModelForm): ... class Meta: ... model = Price ... exclude = ('quantity',) >>> form = PriceForm({'price': '6.00'}) >>> form.is_valid() -True +Traceback (most recent call last): + ... +UnresolvableValidationError: {'quantity': [u'This field cannot be null.']} # Unique & unique together with null values >>> class BookForm(ModelForm): diff --git a/tests/modeltests/model_formsets/models.py b/tests/modeltests/model_formsets/models.py index 1644ddf37b..5eab202962 100644 --- a/tests/modeltests/model_formsets/models.py +++ b/tests/modeltests/model_formsets/models.py @@ -543,10 +543,6 @@ This is used in the admin for save_as functionality. ... 'book_set-2-title': '', ... } ->>> formset = AuthorBooksFormSet(data, instance=Author(), save_as_new=True) ->>> formset.is_valid() -True - >>> new_author = Author.objects.create(name='Charles Baudelaire') >>> formset = AuthorBooksFormSet(data, instance=new_author, save_as_new=True) >>> [book for book in formset.save() if book.author.pk == new_author.pk] @@ -1035,19 +1031,6 @@ False >>> formset._non_form_errors [u'Please correct the duplicate data for price and quantity, which must be unique.'] -# only the price field is specified, this should skip any unique checks since the unique_together is not fulfilled. -# this will fail with a KeyError if broken. ->>> FormSet = modelformset_factory(Price, fields=("price",), extra=2) ->>> data = { -... 'form-TOTAL_FORMS': '2', -... 'form-INITIAL_FORMS': '0', -... 'form-0-price': '24', -... 'form-1-price': '24', -... } ->>> formset = FormSet(data) ->>> formset.is_valid() -True - >>> FormSet = inlineformset_factory(Author, Book, extra=0) >>> author = Author.objects.order_by('id')[0] >>> book_ids = author.book_set.values_list('id', flat=True) diff --git a/tests/modeltests/validation/__init__.py b/tests/modeltests/validation/__init__.py new file mode 100644 index 0000000000..d0a7d19d49 --- /dev/null +++ b/tests/modeltests/validation/__init__.py @@ -0,0 +1,21 @@ +import unittest + +from django.core.exceptions import ValidationError + +class ValidationTestCase(unittest.TestCase): + def assertFailsValidation(self, clean, failed_fields): + self.assertRaises(ValidationError, clean) + try: + clean() + except ValidationError, e: + self.assertEquals(sorted(failed_fields), sorted(e.message_dict.keys())) + + def assertFieldFailsValidationWithMessage(self, clean, field_name, message): + self.assertRaises(ValidationError, clean) + try: + clean() + except ValidationError, e: + self.assertTrue(field_name in e.message_dict) + self.assertEquals(message, e.message_dict[field_name]) + + diff --git a/tests/modeltests/validation/models.py b/tests/modeltests/validation/models.py new file mode 100644 index 0000000000..f1b0c5188c --- /dev/null +++ b/tests/modeltests/validation/models.py @@ -0,0 +1,53 @@ +from datetime import datetime +from django.core.exceptions import ValidationError +from django.db import models +from django.test import TestCase + + +def validate_answer_to_universe(value): + if value != 42: + raise ValidationError('This is not the answer to life, universe and everything!', code='not42') + +class ModelToValidate(models.Model): + name = models.CharField(max_length=100) + created = models.DateTimeField(default=datetime.now) + number = models.IntegerField() + parent = models.ForeignKey('self', blank=True, null=True) + email = models.EmailField(blank=True) + url = models.URLField(blank=True) + f_with_custom_validator = models.IntegerField(blank=True, null=True, validators=[validate_answer_to_universe]) + + def validate(self): + super(ModelToValidate, self).validate() + if self.number == 11: + raise ValidationError('Invalid number supplied!') + +class UniqueFieldsModel(models.Model): + unique_charfield = models.CharField(max_length=100, unique=True) + unique_integerfield = models.IntegerField(unique=True) + non_unique_field = models.IntegerField() + +class CustomPKModel(models.Model): + my_pk_field = models.CharField(max_length=100, primary_key=True) + +class UniqueTogetherModel(models.Model): + cfield = models.CharField(max_length=100) + ifield = models.IntegerField() + efield = models.EmailField() + + class Meta: + unique_together = (('ifield', 'cfield',),('ifield', 'efield'), ) + +class UniqueForDateModel(models.Model): + start_date = models.DateField() + end_date = models.DateTimeField() + count = models.IntegerField(unique_for_date="start_date", unique_for_year="end_date") + order = models.IntegerField(unique_for_month="end_date") + name = models.CharField(max_length=100) + +class CustomMessagesModel(models.Model): + other = models.IntegerField(blank=True, null=True) + number = models.IntegerField( + error_messages={'null': 'NULL', 'not42': 'AAARGH', 'not_equal': '%s != me'}, + validators=[validate_answer_to_universe] + ) diff --git a/tests/modeltests/validation/test_custom_messages.py b/tests/modeltests/validation/test_custom_messages.py new file mode 100644 index 0000000000..9a958a0a3f --- /dev/null +++ b/tests/modeltests/validation/test_custom_messages.py @@ -0,0 +1,13 @@ +from modeltests.validation import ValidationTestCase +from models import CustomMessagesModel + + +class CustomMessagesTest(ValidationTestCase): + def test_custom_simple_validator_message(self): + cmm = CustomMessagesModel(number=12) + self.assertFieldFailsValidationWithMessage(cmm.full_validate, 'number', ['AAARGH']) + + def test_custom_null_message(self): + cmm = CustomMessagesModel() + self.assertFieldFailsValidationWithMessage(cmm.full_validate, 'number', ['NULL']) + diff --git a/tests/modeltests/validation/test_unique.py b/tests/modeltests/validation/test_unique.py new file mode 100644 index 0000000000..cbb56aa8f5 --- /dev/null +++ b/tests/modeltests/validation/test_unique.py @@ -0,0 +1,58 @@ +import unittest +from django.conf import settings +from django.db import connection +from models import CustomPKModel, UniqueTogetherModel, UniqueFieldsModel, UniqueForDateModel, ModelToValidate + + +class GetUniqueCheckTests(unittest.TestCase): + def test_unique_fields_get_collected(self): + m = UniqueFieldsModel() + self.assertEqual( + ([('id',), ('unique_charfield',), ('unique_integerfield',)], []), + m._get_unique_checks() + ) + + def test_unique_together_gets_picked_up(self): + m = UniqueTogetherModel() + self.assertEqual( + ([('ifield', 'cfield',),('ifield', 'efield'), ('id',), ], []), + m._get_unique_checks() + ) + + def test_primary_key_is_considered_unique(self): + m = CustomPKModel() + self.assertEqual(([('my_pk_field',)], []), m._get_unique_checks()) + + def test_unique_for_date_gets_picked_up(self): + m = UniqueForDateModel() + self.assertEqual(( + [('id',)], + [('date', 'count', 'start_date'), ('year', 'count', 'end_date'), ('month', 'order', 'end_date')] + ), m._get_unique_checks() + ) + +class PerformUniqueChecksTest(unittest.TestCase): + def setUp(self): + # Set debug to True to gain access to connection.queries. + self._old_debug, settings.DEBUG = settings.DEBUG, True + super(PerformUniqueChecksTest, self).setUp() + + def tearDown(self): + # Restore old debug value. + settings.DEBUG = self._old_debug + super(PerformUniqueChecksTest, self).tearDown() + + def test_primary_key_unique_check_performed_when_adding(self): + """Regression test for #12132""" + l = len(connection.queries) + mtv = ModelToValidate(number=10, name='Some Name') + setattr(mtv, '_adding', True) + mtv.full_validate() + self.assertEqual(l+1, len(connection.queries)) + + def test_primary_key_unique_check_not_performed_when_not_adding(self): + """Regression test for #12132""" + l = len(connection.queries) + mtv = ModelToValidate(number=10, name='Some Name') + mtv.full_validate() + self.assertEqual(l, len(connection.queries)) diff --git a/tests/modeltests/validation/tests.py b/tests/modeltests/validation/tests.py new file mode 100644 index 0000000000..c00070b2ab --- /dev/null +++ b/tests/modeltests/validation/tests.py @@ -0,0 +1,58 @@ +from django.core.exceptions import ValidationError, NON_FIELD_ERRORS +from django.db import models + +from modeltests.validation import ValidationTestCase +from models import * + +from validators import TestModelsWithValidators +from test_unique import GetUniqueCheckTests, PerformUniqueChecksTest +from test_custom_messages import CustomMessagesTest + + +class BaseModelValidationTests(ValidationTestCase): + + def test_missing_required_field_raises_error(self): + mtv = ModelToValidate(f_with_custom_validator=42) + self.assertFailsValidation(mtv.full_validate, ['name', 'number']) + + def test_with_correct_value_model_validates(self): + mtv = ModelToValidate(number=10, name='Some Name') + self.assertEqual(None, mtv.full_validate()) + + def test_custom_validate_method_is_called(self): + mtv = ModelToValidate(number=11) + self.assertFailsValidation(mtv.full_validate, [NON_FIELD_ERRORS, 'name']) + + def test_wrong_FK_value_raises_error(self): + mtv=ModelToValidate(number=10, name='Some Name', parent_id=3) + self.assertFailsValidation(mtv.full_validate, ['parent']) + + def test_correct_FK_value_validates(self): + parent = ModelToValidate.objects.create(number=10, name='Some Name') + mtv=ModelToValidate(number=10, name='Some Name', parent_id=parent.pk) + self.assertEqual(None, mtv.full_validate()) + + def test_wrong_email_value_raises_error(self): + mtv = ModelToValidate(number=10, name='Some Name', email='not-an-email') + self.assertFailsValidation(mtv.full_validate, ['email']) + + def test_correct_email_value_passes(self): + mtv = ModelToValidate(number=10, name='Some Name', email='valid@email.com') + self.assertEqual(None, mtv.full_validate()) + + def test_wrong_url_value_raises_error(self): + mtv = ModelToValidate(number=10, name='Some Name', url='not a url') + self.assertFieldFailsValidationWithMessage(mtv.full_validate, 'url', [u'Enter a valid value.']) + + def test_correct_url_but_nonexisting_gives_404(self): + mtv = ModelToValidate(number=10, name='Some Name', url='http://google.com/we-love-microsoft.html') + self.assertFieldFailsValidationWithMessage(mtv.full_validate, 'url', [u'This URL appears to be a broken link.']) + + def test_correct_url_value_passes(self): + mtv = ModelToValidate(number=10, name='Some Name', url='http://www.djangoproject.com/') + self.assertEqual(None, mtv.full_validate()) # This will fail if there's no Internet connection + + def test_text_greater_that_charfields_max_length_eaises_erros(self): + mtv = ModelToValidate(number=10, name='Some Name'*100) + self.assertFailsValidation(mtv.full_validate, ['name',]) + diff --git a/tests/modeltests/validation/validators.py b/tests/modeltests/validation/validators.py new file mode 100644 index 0000000000..dc4cd4eba1 --- /dev/null +++ b/tests/modeltests/validation/validators.py @@ -0,0 +1,18 @@ +from unittest import TestCase +from modeltests.validation import ValidationTestCase +from models import * + + +class TestModelsWithValidators(ValidationTestCase): + def test_custom_validator_passes_for_correct_value(self): + mtv = ModelToValidate(number=10, name='Some Name', f_with_custom_validator=42) + self.assertEqual(None, mtv.full_validate()) + + def test_custom_validator_raises_error_for_incorrect_value(self): + mtv = ModelToValidate(number=10, name='Some Name', f_with_custom_validator=12) + self.assertFailsValidation(mtv.full_validate, ['f_with_custom_validator']) + self.assertFieldFailsValidationWithMessage( + mtv.full_validate, + 'f_with_custom_validator', + [u'This is not the answer to life, universe and everything!'] + ) diff --git a/tests/modeltests/validators/tests.py b/tests/modeltests/validators/tests.py new file mode 100644 index 0000000000..f36d8c4713 --- /dev/null +++ b/tests/modeltests/validators/tests.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +import re +import types +from unittest import TestCase +from datetime import datetime, timedelta +from django.core.exceptions import ValidationError +from django.core.validators import * + +NOW = datetime.now() + +TEST_DATA = ( + # (validator, value, expected), + (validate_integer, '42', None), + (validate_integer, '-42', None), + (validate_integer, -42, None), + (validate_integer, -42.5, None), + + (validate_integer, None, ValidationError), + (validate_integer, 'a', ValidationError), + + (validate_email, 'email@here.com', None), + (validate_email, 'weirder-email@here.and.there.com', None), + + (validate_email, None, ValidationError), + (validate_email, '', ValidationError), + (validate_email, 'abc', ValidationError), + (validate_email, 'a @x.cz', ValidationError), + (validate_email, 'something@@somewhere.com', ValidationError), + + (validate_slug, 'slug-ok', None), + (validate_slug, 'longer-slug-still-ok', None), + (validate_slug, '--------', None), + (validate_slug, 'nohyphensoranything', None), + + (validate_slug, '', ValidationError), + (validate_slug, ' text ', ValidationError), + (validate_slug, ' ', ValidationError), + (validate_slug, 'some@mail.com', ValidationError), + (validate_slug, '你好', ValidationError), + (validate_slug, '\n', ValidationError), + + (validate_ipv4_address, '1.1.1.1', None), + (validate_ipv4_address, '255.0.0.0', None), + (validate_ipv4_address, '0.0.0.0', None), + + (validate_ipv4_address, '256.1.1.1', ValidationError), + (validate_ipv4_address, '25.1.1.', ValidationError), + (validate_ipv4_address, '25,1,1,1', ValidationError), + (validate_ipv4_address, '25.1 .1.1', ValidationError), + + (validate_comma_separated_integer_list, '1', None), + (validate_comma_separated_integer_list, '1,2,3', None), + (validate_comma_separated_integer_list, '1,2,3,', None), + + (validate_comma_separated_integer_list, '', ValidationError), + (validate_comma_separated_integer_list, 'a,b,c', ValidationError), + (validate_comma_separated_integer_list, '1, 2, 3', ValidationError), + + (MaxValueValidator(10), 10, None), + (MaxValueValidator(10), -10, None), + (MaxValueValidator(10), 0, None), + (MaxValueValidator(NOW), NOW, None), + (MaxValueValidator(NOW), NOW - timedelta(days=1), None), + + (MaxValueValidator(0), 1, ValidationError), + (MaxValueValidator(NOW), NOW + timedelta(days=1), ValidationError), + + (MinValueValidator(-10), -10, None), + (MinValueValidator(-10), 10, None), + (MinValueValidator(-10), 0, None), + (MinValueValidator(NOW), NOW, None), + (MinValueValidator(NOW), NOW + timedelta(days=1), None), + + (MinValueValidator(0), -1, ValidationError), + (MinValueValidator(NOW), NOW - timedelta(days=1), ValidationError), + + (MaxLengthValidator(10), '', None), + (MaxLengthValidator(10), 10*'x', None), + + (MaxLengthValidator(10), 15*'x', ValidationError), + + (MinLengthValidator(10), 15*'x', None), + (MinLengthValidator(10), 10*'x', None), + + (MinLengthValidator(10), '', ValidationError), + + (URLValidator(), 'http://www.djangoproject.com/', None), + (URLValidator(), 'http://localhost/', None), + (URLValidator(), 'http://example.com/', None), + (URLValidator(), 'http://www.example.com/', None), + (URLValidator(), 'http://www.example.com:8000/test', None), + (URLValidator(), 'http://valid-with-hyphens.com/', None), + (URLValidator(), 'http://subdomain.domain.com/', None), + (URLValidator(), 'http://200.8.9.10/', None), + (URLValidator(), 'http://200.8.9.10:8000/test', None), + (URLValidator(), 'http://valid-----hyphens.com/', None), + (URLValidator(), 'http://example.com?something=value', None), + (URLValidator(), 'http://example.com/index.php?something=value&another=value2', None), + + (URLValidator(), 'foo', ValidationError), + (URLValidator(), 'http://', ValidationError), + (URLValidator(), 'http://example', ValidationError), + (URLValidator(), 'http://example.', ValidationError), + (URLValidator(), 'http://.com', ValidationError), + (URLValidator(), 'http://invalid-.com', ValidationError), + (URLValidator(), 'http://-invalid.com', ValidationError), + (URLValidator(), 'http://inv-.alid-.com', ValidationError), + (URLValidator(), 'http://inv-.-alid.com', ValidationError), + + (BaseValidator(True), True, None), + (BaseValidator(True), False, ValidationError), + + (RegexValidator('.*'), '', None), + (RegexValidator(re.compile('.*')), '', None), + (RegexValidator('.*'), 'xxxxx', None), + + (RegexValidator('x'), 'y', ValidationError), + (RegexValidator(re.compile('x')), 'y', ValidationError), +) + +def create_simple_test_method(validator, expected, value, num): + if isinstance(expected, type) and issubclass(expected, Exception): + test_mask = 'test_%s_raises_error_%d' + def test_func(self): + self.assertRaises(expected, validator, value) + else: + test_mask = 'test_%s_%d' + def test_func(self): + self.assertEqual(expected, validator(value)) + if isinstance(validator, types.FunctionType): + val_name = validator.__name__ + else: + val_name = validator.__class__.__name__ + test_name = test_mask % (val_name, num) + return test_name, test_func + +# Dynamically assemble a test class with the contents of TEST_DATA + +class TestSimpleValidators(TestCase): + pass + +test_counter = 0 +for validator, value, expected in TEST_DATA: + name, method = create_simple_test_method(validator, expected, value, test_counter) + setattr(TestSimpleValidators, name, method) + test_counter += 1 diff --git a/tests/regressiontests/forms/error_messages.py b/tests/regressiontests/forms/error_messages.py index b7224dbde0..038fa39f6b 100644 --- a/tests/regressiontests/forms/error_messages.py +++ b/tests/regressiontests/forms/error_messages.py @@ -6,8 +6,8 @@ tests = r""" # CharField ################################################################### >>> e = {'required': 'REQUIRED'} ->>> e['min_length'] = 'LENGTH %(length)s, MIN LENGTH %(min)s' ->>> e['max_length'] = 'LENGTH %(length)s, MAX LENGTH %(max)s' +>>> e['min_length'] = 'LENGTH %(show_value)s, MIN LENGTH %(limit_value)s' +>>> e['max_length'] = 'LENGTH %(show_value)s, MAX LENGTH %(limit_value)s' >>> f = CharField(min_length=5, max_length=10, error_messages=e) >>> f.clean('') Traceback (most recent call last): @@ -26,8 +26,8 @@ ValidationError: [u'LENGTH 11, MAX LENGTH 10'] >>> e = {'required': 'REQUIRED'} >>> e['invalid'] = 'INVALID' ->>> e['min_value'] = 'MIN VALUE IS %s' ->>> e['max_value'] = 'MAX VALUE IS %s' +>>> e['min_value'] = 'MIN VALUE IS %(limit_value)s' +>>> e['max_value'] = 'MAX VALUE IS %(limit_value)s' >>> f = IntegerField(min_value=5, max_value=10, error_messages=e) >>> f.clean('') Traceback (most recent call last): @@ -50,8 +50,8 @@ ValidationError: [u'MAX VALUE IS 10'] >>> e = {'required': 'REQUIRED'} >>> e['invalid'] = 'INVALID' ->>> e['min_value'] = 'MIN VALUE IS %s' ->>> e['max_value'] = 'MAX VALUE IS %s' +>>> e['min_value'] = 'MIN VALUE IS %(limit_value)s' +>>> e['max_value'] = 'MAX VALUE IS %(limit_value)s' >>> f = FloatField(min_value=5, max_value=10, error_messages=e) >>> f.clean('') Traceback (most recent call last): @@ -74,8 +74,8 @@ ValidationError: [u'MAX VALUE IS 10'] >>> e = {'required': 'REQUIRED'} >>> e['invalid'] = 'INVALID' ->>> e['min_value'] = 'MIN VALUE IS %s' ->>> e['max_value'] = 'MAX VALUE IS %s' +>>> e['min_value'] = 'MIN VALUE IS %(limit_value)s' +>>> e['max_value'] = 'MAX VALUE IS %(limit_value)s' >>> e['max_digits'] = 'MAX DIGITS IS %s' >>> e['max_decimal_places'] = 'MAX DP IS %s' >>> e['max_whole_digits'] = 'MAX DIGITS BEFORE DP IS %s' @@ -156,8 +156,8 @@ ValidationError: [u'INVALID'] >>> e = {'required': 'REQUIRED'} >>> e['invalid'] = 'INVALID' ->>> e['min_length'] = 'LENGTH %(length)s, MIN LENGTH %(min)s' ->>> e['max_length'] = 'LENGTH %(length)s, MAX LENGTH %(max)s' +>>> e['min_length'] = 'LENGTH %(show_value)s, MIN LENGTH %(limit_value)s' +>>> e['max_length'] = 'LENGTH %(show_value)s, MAX LENGTH %(limit_value)s' >>> f = RegexField(r'^\d+$', min_length=5, max_length=10, error_messages=e) >>> f.clean('') Traceback (most recent call last): @@ -180,8 +180,8 @@ ValidationError: [u'LENGTH 11, MAX LENGTH 10'] >>> e = {'required': 'REQUIRED'} >>> e['invalid'] = 'INVALID' ->>> e['min_length'] = 'LENGTH %(length)s, MIN LENGTH %(min)s' ->>> e['max_length'] = 'LENGTH %(length)s, MAX LENGTH %(max)s' +>>> e['min_length'] = 'LENGTH %(show_value)s, MIN LENGTH %(limit_value)s' +>>> e['max_length'] = 'LENGTH %(show_value)s, MAX LENGTH %(limit_value)s' >>> f = EmailField(min_length=8, max_length=10, error_messages=e) >>> f.clean('') Traceback (most recent call last): diff --git a/tests/regressiontests/forms/fields.py b/tests/regressiontests/forms/fields.py index c9736d38e1..87330904df 100644 --- a/tests/regressiontests/forms/fields.py +++ b/tests/regressiontests/forms/fields.py @@ -386,7 +386,7 @@ class FieldsTests(TestCase): def test_regexfield_31(self): f = RegexField('^\d+$', min_length=5, max_length=10) self.assertRaisesErrorWithMessage(ValidationError, "[u'Ensure this value has at least 5 characters (it has 3).']", f.clean, '123') - self.assertRaisesErrorWithMessage(ValidationError, "[u'Ensure this value has at least 5 characters (it has 3).']", f.clean, 'abc') + self.assertRaisesErrorWithMessage(ValidationError, "[u'Ensure this value has at least 5 characters (it has 3).', u'Enter a valid value.']", f.clean, 'abc') self.assertEqual(u'12345', f.clean('12345')) self.assertEqual(u'1234567890', f.clean('1234567890')) self.assertRaisesErrorWithMessage(ValidationError, "[u'Ensure this value has at most 10 characters (it has 11).']", f.clean, '12345678901') @@ -548,6 +548,10 @@ class FieldsTests(TestCase): self.assertEqual(u'http://example.com/', f.clean('http://example.com')) self.assertEqual(u'http://example.com/test', f.clean('http://example.com/test')) + def test_urlfield_ticket11826(self): + f = URLField() + self.assertEqual(u'http://example.com/?some_param=some_value', f.clean('http://example.com?some_param=some_value')) + # BooleanField ################################################################ def test_booleanfield_44(self): diff --git a/tests/regressiontests/forms/localflavor/ar.py b/tests/regressiontests/forms/localflavor/ar.py index e1c827c4a0..c7967c84da 100644 --- a/tests/regressiontests/forms/localflavor/ar.py +++ b/tests/regressiontests/forms/localflavor/ar.py @@ -28,7 +28,7 @@ u'C1064AAB' >>> f.clean('C1064AABB') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at most 8 characters (it has 9).'] +ValidationError: [u'Ensure this value has at most 8 characters (it has 9).', u'Enter a postal code in the format NNNN or ANNNNAAA.'] >>> f.clean('C1064AA') Traceback (most recent call last): ... @@ -44,7 +44,7 @@ ValidationError: [u'Enter a postal code in the format NNNN or ANNNNAAA.'] >>> f.clean('500') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at least 4 characters (it has 3).'] +ValidationError: [u'Ensure this value has at least 4 characters (it has 3).', u'Enter a postal code in the format NNNN or ANNNNAAA.'] >>> f.clean('5PPP') Traceback (most recent call last): ... @@ -78,7 +78,7 @@ u'C1064AAB' >>> f.clean('C1064AABB') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at most 8 characters (it has 9).'] +ValidationError: [u'Ensure this value has at most 8 characters (it has 9).', u'Enter a postal code in the format NNNN or ANNNNAAA.'] >>> f.clean('C1064AA') Traceback (most recent call last): ... @@ -94,7 +94,7 @@ ValidationError: [u'Enter a postal code in the format NNNN or ANNNNAAA.'] >>> f.clean('500') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at least 4 characters (it has 3).'] +ValidationError: [u'Ensure this value has at least 4 characters (it has 3).', u'Enter a postal code in the format NNNN or ANNNNAAA.'] >>> f.clean('5PPP') Traceback (most recent call last): ... diff --git a/tests/regressiontests/forms/localflavor/is_.py b/tests/regressiontests/forms/localflavor/is_.py index 6851441a79..e71c2dd8de 100644 --- a/tests/regressiontests/forms/localflavor/is_.py +++ b/tests/regressiontests/forms/localflavor/is_.py @@ -15,11 +15,11 @@ u'230880-3449' >>> f.clean('230880343') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at least 10 characters (it has 9).'] +ValidationError: [u'Ensure this value has at least 10 characters (it has 9).', u'Enter a valid Icelandic identification number. The format is XXXXXX-XXXX.'] >>> f.clean('230880343234') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at most 11 characters (it has 12).'] +ValidationError: [u'Ensure this value has at most 11 characters (it has 12).', u'Enter a valid Icelandic identification number. The format is XXXXXX-XXXX.'] >>> f.clean('abcdefghijk') Traceback (most recent call last): ... @@ -61,18 +61,18 @@ ValidationError: [u'Enter a valid value.'] >>> f.clean('123456') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at least 7 characters (it has 6).'] +ValidationError: [u'Ensure this value has at least 7 characters (it has 6).', u'Enter a valid value.'] >>> f.clean('123456555') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at most 8 characters (it has 9).'] +ValidationError: [u'Ensure this value has at most 8 characters (it has 9).', u'Enter a valid value.'] >>> f.clean('abcdefg') Traceback (most recent call last): ValidationError: [u'Enter a valid value.'] >>> f.clean(' 1234567 ') Traceback (most recent call last): ... -ValidationError: [u'Ensure this value has at most 8 characters (it has 9).'] +ValidationError: [u'Ensure this value has at most 8 characters (it has 9).', u'Enter a valid value.'] >>> f.clean(' 12367 ') Traceback (most recent call last): ... diff --git a/tests/regressiontests/forms/tests.py b/tests/regressiontests/forms/tests.py index 89140f04b1..db70500909 100644 --- a/tests/regressiontests/forms/tests.py +++ b/tests/regressiontests/forms/tests.py @@ -38,6 +38,7 @@ from formsets import tests as formset_tests from media import media_tests from fields import FieldsTests +from validators import TestFieldWithValidators __test__ = { 'extra_tests': extra_tests, diff --git a/tests/regressiontests/forms/util.py b/tests/regressiontests/forms/util.py index 845ddeaadb..f365c8c1ae 100644 --- a/tests/regressiontests/forms/util.py +++ b/tests/regressiontests/forms/util.py @@ -5,6 +5,7 @@ Tests for forms/util.py module. tests = r""" >>> from django.forms.util import * +>>> from django.core.exceptions import ValidationError >>> from django.utils.translation import ugettext_lazy ########### @@ -24,36 +25,36 @@ u'' ################### # Can take a string. ->>> print ValidationError("There was an error.").messages +>>> print ErrorList(ValidationError("There was an error.").messages)
  • There was an error.
# Can take a unicode string. ->>> print ValidationError(u"Not \u03C0.").messages +>>> print ErrorList(ValidationError(u"Not \u03C0.").messages)
  • Not π.
# Can take a lazy string. ->>> print ValidationError(ugettext_lazy("Error.")).messages +>>> print ErrorList(ValidationError(ugettext_lazy("Error.")).messages)
  • Error.
# Can take a list. ->>> print ValidationError(["Error one.", "Error two."]).messages +>>> print ErrorList(ValidationError(["Error one.", "Error two."]).messages)
  • Error one.
  • Error two.
# Can take a mixture in a list. ->>> print ValidationError(["First error.", u"Not \u03C0.", ugettext_lazy("Error.")]).messages +>>> print ErrorList(ValidationError(["First error.", u"Not \u03C0.", ugettext_lazy("Error.")]).messages)
  • First error.
  • Not π.
  • Error.
>>> class VeryBadError: ... def __unicode__(self): return u"A very bad error." # Can take a non-string. ->>> print ValidationError(VeryBadError()).messages +>>> print ErrorList(ValidationError(VeryBadError()).messages)
  • A very bad error.
# Escapes non-safe input but not input marked safe. >>> example = 'Example of link: example' ->>> print ValidationError(example).messages +>>> print ErrorList([example])
  • Example of link: <a href="http://www.example.com/">example</a>
->>> print ValidationError(mark_safe(example)).messages +>>> print ErrorList([mark_safe(example)]) """ diff --git a/tests/regressiontests/forms/validators.py b/tests/regressiontests/forms/validators.py new file mode 100644 index 0000000000..ed8e8fbd9a --- /dev/null +++ b/tests/regressiontests/forms/validators.py @@ -0,0 +1,17 @@ +from unittest import TestCase + +from django import forms +from django.core import validators +from django.core.exceptions import ValidationError + + +class TestFieldWithValidators(TestCase): + def test_all_errors_get_reported(self): + field = forms.CharField( + validators=[validators.validate_integer, validators.validate_email] + ) + self.assertRaises(ValidationError, field.clean, 'not int nor mail') + try: + field.clean('not int nor mail') + except ValidationError, e: + self.assertEqual(2, len(e.messages)) diff --git a/tests/regressiontests/inline_formsets/tests.py b/tests/regressiontests/inline_formsets/tests.py index aef6b3f10a..be313f3bf6 100644 --- a/tests/regressiontests/inline_formsets/tests.py +++ b/tests/regressiontests/inline_formsets/tests.py @@ -81,7 +81,7 @@ class DeletionTests(TestCase): regression for #10750 """ # exclude some required field from the forms - ChildFormSet = inlineformset_factory(School, Child, exclude=['father', 'mother']) + ChildFormSet = inlineformset_factory(School, Child) school = School.objects.create(name=u'test') mother = Parent.objects.create(name=u'mother') father = Parent.objects.create(name=u'father') @@ -89,13 +89,13 @@ class DeletionTests(TestCase): 'child_set-TOTAL_FORMS': u'1', 'child_set-INITIAL_FORMS': u'0', 'child_set-0-name': u'child', + 'child_set-0-mother': unicode(mother.pk), + 'child_set-0-father': unicode(father.pk), } formset = ChildFormSet(data, instance=school) self.assertEqual(formset.is_valid(), True) objects = formset.save(commit=False) - for obj in objects: - obj.mother = mother - obj.father = father - obj.save() + self.assertEqual(school.child_set.count(), 0) + objects[0].save() self.assertEqual(school.child_set.count(), 1) diff --git a/tests/regressiontests/model_fields/tests.py b/tests/regressiontests/model_fields/tests.py index f31193e269..ea7b49ab7e 100644 --- a/tests/regressiontests/model_fields/tests.py +++ b/tests/regressiontests/model_fields/tests.py @@ -147,6 +147,58 @@ class SlugFieldTests(django.test.TestCase): bs = BigS.objects.get(pk=bs.pk) self.assertEqual(bs.s, 'slug'*50) + +class ValidationTest(django.test.TestCase): + def test_charfield_raises_error_on_empty_string(self): + f = models.CharField() + self.assertRaises(ValidationError, f.clean, "", None) + + def test_charfield_cleans_empty_string_when_blank_true(self): + f = models.CharField(blank=True) + self.assertEqual('', f.clean('', None)) + + def test_integerfield_cleans_valid_string(self): + f = models.IntegerField() + self.assertEqual(2, f.clean('2', None)) + + def test_integerfield_raises_error_on_invalid_intput(self): + f = models.IntegerField() + self.assertRaises(ValidationError, f.clean, "a", None) + + def test_charfield_with_choices_cleans_valid_choice(self): + f = models.CharField(max_length=1, choices=[('a','A'), ('b','B')]) + self.assertEqual('a', f.clean('a', None)) + + def test_charfield_with_choices_raises_error_on_invalid_choice(self): + f = models.CharField(choices=[('a','A'), ('b','B')]) + self.assertRaises(ValidationError, f.clean, "not a", None) + + def test_nullable_integerfield_raises_error_with_blank_false(self): + f = models.IntegerField(null=True, blank=False) + self.assertRaises(ValidationError, f.clean, None, None) + + def test_nullable_integerfield_cleans_none_on_null_and_blank_true(self): + f = models.IntegerField(null=True, blank=True) + self.assertEqual(None, f.clean(None, None)) + + def test_integerfield_raises_error_on_empty_input(self): + f = models.IntegerField(null=False) + self.assertRaises(ValidationError, f.clean, None, None) + self.assertRaises(ValidationError, f.clean, '', None) + + def test_charfield_raises_error_on_empty_input(self): + f = models.CharField(null=False) + self.assertRaises(ValidationError, f.clean, None, None) + + def test_datefield_cleans_date(self): + f = models.DateField() + self.assertEqual(datetime.date(2008, 10, 10), f.clean('2008-10-10', None)) + + def test_boolean_field_doesnt_accept_empty_input(self): + f = models.BooleanField() + self.assertRaises(ValidationError, f.clean, None, None) + + class BigIntegerFieldTests(django.test.TestCase): def test_limits(self): # Ensure that values that are right at the limits can be saved diff --git a/tests/regressiontests/views/views.py b/tests/regressiontests/views/views.py index 3447cbfdfd..20ee56f985 100644 --- a/tests/regressiontests/views/views.py +++ b/tests/regressiontests/views/views.py @@ -24,7 +24,7 @@ def custom_create(request): model = Article def save(self, *args, **kwargs): - self.cleaned_data['slug'] = 'some-other-slug' + self.instance.slug = 'some-other-slug' return super(SlugChangingArticleForm, self).save(*args, **kwargs) return create_object(request,