From bbc62cdccc6e0e630c3a4675399f3f42dd568995 Mon Sep 17 00:00:00 2001 From: Adrian Holovaty Date: Mon, 13 Mar 2006 01:33:45 +0000 Subject: [PATCH] magic-removal: Added first bit of validation-aware models. Model objects now have a validate() method. See docstrings in db.models.base and db.models.fields.__init__ for information. Also added unit tests for all the currently supported validation. git-svn-id: http://code.djangoproject.com/svn/django/branches/magic-removal@2518 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/db/models/base.py | 26 ++++- django/db/models/fields/__init__.py | 102 +++++++++++++++- tests/modeltests/validation/__init__.py | 0 tests/modeltests/validation/models.py | 147 ++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 tests/modeltests/validation/__init__.py create mode 100644 tests/modeltests/validation/models.py diff --git a/django/db/models/base.py b/django/db/models/base.py index 19dce10697..013c4f5d5b 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -1,5 +1,7 @@ import django.db.models.manipulators import django.db.models.manager +from django.core import validators +from django.core.exceptions import ObjectDoesNotExist from django.db.models.fields import AutoField, ImageField, FieldDoesNotExist from django.db.models.fields.related import OneToOne, ManyToOne from django.db.models.related import RelatedObject @@ -9,7 +11,6 @@ from django.db import connection, backend, transaction from django.db.models import signals from django.db.models.loading import register_models from django.dispatch import dispatcher -from django.core.exceptions import ObjectDoesNotExist from django.utils.datastructures import SortedDict from django.utils.functional import curry from django.conf import settings @@ -38,6 +39,7 @@ class ModelBase(type): # Build complete list of parents for base in bases: + # TODO: Checking for the presence of '_meta' is hackish. if '_meta' in dir(base): new_class._meta.parents.append(base) new_class._meta.parents.extend(base._meta.parents) @@ -196,6 +198,28 @@ class Model(object): save.alters_data = True + def validate(self): + """ + First coerces all fields on this instance to their proper Python types. + Then runs validation on every field. Returns a dictionary of + field_name -> error_list. + """ + error_dict = {} + invalid_python = {} + for f in self._meta.fields: + try: + setattr(self, f.attname, f.to_python(getattr(self, f.attname, f.get_default()))) + except validators.ValidationError, e: + error_dict[f.name] = e.messages + invalid_python[f.name] = 1 + for f in self._meta.fields: + if f.name in invalid_python: + continue + errors = f.validate_full(getattr(self, f.attname, f.get_default()), self.__dict__) + if errors: + error_dict[f.name] = errors + return error_dict + def _collect_sub_objects(self, seen_objs): """ Recursively populates seen_objs with all objects related to this object. diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 4e23d9a5e7..ceb96e6e81 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -6,8 +6,8 @@ from django import forms from django.core.exceptions import ObjectDoesNotExist from django.utils.functional import curry, lazy from django.utils.text import capfirst -from django.utils.translation import gettext_lazy, ngettext -import datetime, os +from django.utils.translation import gettext, gettext_lazy, ngettext +import datetime, os, time class NOT_PROVIDED: pass @@ -37,7 +37,7 @@ def manipulator_validator_unique(f, opts, self, field_data, all_data): return if getattr(self, 'original_object', None) and self.original_object._get_pk_val() == old_obj._get_pk_val(): return - raise validators.ValidationError, _("%(optname)s with this %(fieldname)s already exists.") % {'optname': capfirst(opts.verbose_name), 'fieldname': f.verbose_name} + raise validators.ValidationError, gettext("%(optname)s with this %(fieldname)s already exists.") % {'optname': capfirst(opts.verbose_name), 'fieldname': f.verbose_name} # A guide to Field parameters: # @@ -96,6 +96,37 @@ class Field(object): # This is needed because bisect does not take a comparison function. return cmp(self.creation_counter, other.creation_counter) + def to_python(self, value): + """ + Converts the input value into the expected Python data type, raising + validators.ValidationError if the data can't be converted. Returns the + converted value. Subclasses should override this. + """ + return value + + def validate_full(self, field_data, all_data): + """ + Returns a list of errors for this field. This is the main interface, + as it encapsulates some basic validation logic used by all fields. + Subclasses should implement validate(), not validate_full(). + """ + if not self.blank and not field_data: + return [gettext_lazy('This field is required.')] + try: + self.validate(field_data, all_data) + except validators.ValidationError, e: + return e.messages + return [] + + def validate(self, field_data, all_data): + """ + Raises validators.ValidationError if field_data has any errors. + Subclasses should override this to specify field-specific validation + logic. This method should assume field_data has already been converted + into the appropriate data type by Field.to_python(). + """ + pass + def set_attributes_from_name(self, name): self.name = name self.attname, self.column = self.get_attname_column() @@ -299,8 +330,17 @@ class AutoField(Field): empty_strings_allowed = False 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 Field.__init__(self, *args, **kwargs) + def to_python(self, value): + if value is None: + return value + try: + return int(value) + except (TypeError, ValueError): + raise validators.ValidationError, gettext("This value must be an integer.") + def get_manipulator_fields(self, opts, manipulator, change, name_prefix='', rel=False, follow=True): if not rel: return [] # Don't add a FormField unless it's in a related context. @@ -327,6 +367,12 @@ class BooleanField(Field): kwargs['blank'] = True Field.__init__(self, *args, **kwargs) + def to_python(self, value): + if value in (True, False): return value + if value is 't': return True + if value is 'f': return False + raise validators.ValidationError, gettext("This value must be either True or False.") + def get_manipulator_field_objs(self): return [forms.CheckboxField] @@ -334,6 +380,17 @@ class CharField(Field): def get_manipulator_field_objs(self): return [forms.TextField] + def to_python(self, value): + if isinstance(value, basestring): + return value + if value is None: + if self.null: + return value + else: + raise validators.ValidationError, gettext_lazy("This field cannot be null.") + return str(value) + +# TODO: Maybe move this into contrib, because it's specialized. class CommaSeparatedIntegerField(CharField): def get_manipulator_field_objs(self): return [forms.CommaSeparatedIntegerField] @@ -348,6 +405,14 @@ class DateField(Field): kwargs['blank'] = True Field.__init__(self, verbose_name, name, **kwargs) + def to_python(self, value): + if isinstance(value, datetime.datetime): + return value.date() + if isinstance(value, datetime.date): + return value + validators.isValidANSIDate(value, None) + return datetime.date(*time.strptime(value, '%Y-%m-%d')[:3]) + def get_db_prep_lookup(self, lookup_type, value): if lookup_type == 'range': value = [str(v) for v in value] @@ -391,6 +456,22 @@ class DateField(Field): return {self.attname: (val is not None and val.strftime("%Y-%m-%d") or '')} class DateTimeField(DateField): + def to_python(self, value): + if isinstance(value, datetime.datetime): + return value + if isinstance(value, datetime.date): + return datetime.datetime(value.year, value.month, value.day) + try: # Seconds are optional, so try converting seconds first. + return datetime.datetime(*time.strptime(value, '%Y-%m-%d %H:%M:%S')[:6]) + except ValueError: + try: # Try without seconds. + return datetime.datetime(*time.strptime(value, '%Y-%m-%d %H:%M')[:5]) + except ValueError: # Try without hour/minutes/seconds. + try: + return datetime.datetime(*time.strptime(value, '%Y-%m-%d')[:3]) + except ValueError: + raise validators.ValidationError, gettext('Enter a valid date/time in YYYY-MM-DD HH:MM format.') + def get_db_prep_save(self, value): # Casts dates into string format for entry into database. if value is not None: @@ -432,10 +513,10 @@ class DateTimeField(DateField): return {date_field: (val is not None and val.strftime("%Y-%m-%d") or ''), time_field: (val is not None and val.strftime("%H:%M:%S") or '')} -class EmailField(Field): +class EmailField(CharField): def __init__(self, *args, **kwargs): kwargs['maxlength'] = 75 - Field.__init__(self, *args, **kwargs) + CharField.__init__(self, *args, **kwargs) def get_internal_type(self): return "CharField" @@ -443,6 +524,9 @@ class EmailField(Field): def get_manipulator_field_objs(self): return [forms.EmailField] + def validate(self, field_data, all_data): + validators.isValidEmail(field_data, all_data) + class FileField(Field): def __init__(self, verbose_name=None, name=None, upload_to='', **kwargs): self.upload_to = upload_to @@ -583,6 +667,9 @@ class IPAddressField(Field): def get_manipulator_field_objs(self): return [forms.IPAddressField] + def validate(self, field_data, all_data): + validators.isValidIPAddress4(field_data, None) + class NullBooleanField(Field): def __init__(self, *args, **kwargs): kwargs['null'] = True @@ -595,6 +682,9 @@ class PhoneNumberField(IntegerField): def get_manipulator_field_objs(self): return [forms.PhoneNumberField] + def validate(self, field_data, all_data): + validators.isValidPhone(field_data, all_data) + class PositiveIntegerField(IntegerField): def get_manipulator_field_objs(self): return [forms.PositiveIntegerField] @@ -626,7 +716,7 @@ class TextField(Field): class TimeField(Field): empty_strings_allowed = False 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 + self.auto_now, self.auto_now_add = auto_now, auto_now_add if auto_now or auto_now_add: kwargs['editable'] = False Field.__init__(self, verbose_name, name, **kwargs) diff --git a/tests/modeltests/validation/__init__.py b/tests/modeltests/validation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/modeltests/validation/models.py b/tests/modeltests/validation/models.py new file mode 100644 index 0000000000..d03fffea25 --- /dev/null +++ b/tests/modeltests/validation/models.py @@ -0,0 +1,147 @@ +""" +XX. Validation + +Each model instance has a validate() method that returns a dictionary of +validation errors in the instance's fields. This method has a side effect +of converting each field to its appropriate Python data type. +""" + +from django.db import models + +class Person(models.Model): + is_child = models.BooleanField() + name = models.CharField(maxlength=20) + birthdate = models.DateField() + favorite_moment = models.DateTimeField() + email = models.EmailField() + + def __repr__(self): + return self.name + +API_TESTS = """ + +>>> import datetime +>>> valid_params = { +... 'is_child': True, +... 'name': 'John', +... 'birthdate': datetime.date(2000, 5, 3), +... 'favorite_moment': datetime.datetime(2002, 4, 3, 13, 23), +... 'email': 'john@example.com' +... } +>>> p = Person(**valid_params) +>>> p.validate() +{} + +>>> p = Person(**dict(valid_params, id='23')) +>>> p.validate() +{} +>>> p.id +23 + +>>> p = Person(**dict(valid_params, id='foo')) +>>> p.validate() +{'id': ['This value must be an integer.']} + +>>> p = Person(**dict(valid_params, id=None)) +>>> p.validate() +{} +>>> repr(p.id) +'None' + +>>> p = Person(**dict(valid_params, is_child='t')) +>>> p.validate() +{} +>>> p.is_child +True + +>>> p = Person(**dict(valid_params, is_child='f')) +>>> p.validate() +{} +>>> p.is_child +False + +>>> p = Person(**dict(valid_params, is_child=True)) +>>> p.validate() +{} +>>> p.is_child +True + +>>> p = Person(**dict(valid_params, is_child=False)) +>>> p.validate() +{} +>>> p.is_child +False + +>>> p = Person(**dict(valid_params, is_child='foo')) +>>> p.validate() +{'is_child': ['This value must be either True or False.']} + +>>> p = Person(**dict(valid_params, name=u'Jose')) +>>> p.validate() +{} +>>> p.name +u'Jose' + +>>> p = Person(**dict(valid_params, name=227)) +>>> p.validate() +{} +>>> p.name +'227' + +>>> p = Person(**dict(valid_params, birthdate=datetime.date(2000, 5, 3))) +>>> p.validate() +{} +>>> p.birthdate +datetime.date(2000, 5, 3) + +>>> p = Person(**dict(valid_params, birthdate=datetime.datetime(2000, 5, 3))) +>>> p.validate() +{} +>>> p.birthdate +datetime.date(2000, 5, 3) + +>>> p = Person(**dict(valid_params, birthdate='2000-05-03')) +>>> p.validate() +{} +>>> p.birthdate +datetime.date(2000, 5, 3) + +>>> p = Person(**dict(valid_params, birthdate='2000-5-3')) +>>> p.validate() +{} +>>> p.birthdate +datetime.date(2000, 5, 3) + +>>> p = Person(**dict(valid_params, birthdate='foo')) +>>> p.validate() +{'birthdate': ['Enter a valid date in YYYY-MM-DD format.']} + +>>> p = Person(**dict(valid_params, favorite_moment=datetime.datetime(2002, 4, 3, 13, 23))) +>>> p.validate() +{} +>>> p.favorite_moment +datetime.datetime(2002, 4, 3, 13, 23) + +>>> p = Person(**dict(valid_params, favorite_moment=datetime.datetime(2002, 4, 3))) +>>> p.validate() +{} +>>> p.favorite_moment +datetime.datetime(2002, 4, 3, 0, 0) + +>>> p = Person(**dict(valid_params, email='john@example.com')) +>>> p.validate() +{} +>>> p.email +'john@example.com' + +>>> p = Person(**dict(valid_params, email=u'john@example.com')) +>>> p.validate() +{} +>>> p.email +u'john@example.com' + +>>> p = Person(**dict(valid_params, email=22)) +>>> p.validate() +{'email': ['Enter a valid e-mail address.']} + +"""