Ticket #2577: validators.py

File validators.py, 23.9 KB (added by henrik_kroeger@…, 18 years ago)
Line 
1"""
2A library of validators that return None and raise ValidationError when the
3provided data isn't valid.
4
5Validators may be callable classes, and they may have an 'always_test'
6attribute. If an 'always_test' attribute exists (regardless of value), the
7validator will *always* be run, regardless of whether its associated
8form field is required.
9"""
10
11from django.conf import settings
12from django.utils.translation import gettext, gettext_lazy, ngettext
13from django.utils.functional import Promise, lazy
14import re
15
16_datere = r'(19|2\d)\d{2}-((?:0?[1-9])|(?:1[0-2]))-((?:0?[1-9])|(?:[12][0-9])|(?:3[0-1]))'
17_timere = r'(?:[01]?[0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?'
18alnum_re = re.compile(r'^\w+$')
19alnumurl_re = re.compile(r'^[-\w/]+$')
20ansi_date_re = re.compile('^%s$' % _datere)
21ansi_time_re = re.compile('^%s$' % _timere)
22ansi_datetime_re = re.compile('^%s %s$' % (_datere, _timere))
23email_re = re.compile(
24 r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom
25 r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*"' # quoted-string
26 r')@(?:[A-Z0-9-]+\.)+[A-Z]{2,6}$', re.IGNORECASE) # domain
27integer_re = re.compile(r'^-?\d+$')
28ip4_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}$')
29phone_re = re.compile(r'^[A-PR-Y0-9]{3}-[A-PR-Y0-9]{3}-[A-PR-Y0-9]{4}$', re.IGNORECASE)
30slug_re = re.compile(r'^[-\w]+$')
31url_re = re.compile(r'^https?://\S+$')
32
33lazy_inter = lazy(lambda a,b: str(a) % b, str)
34
35class ValidationError(Exception):
36 def __init__(self, message):
37 "ValidationError can be passed a string or a list."
38 if isinstance(message, list):
39 self.messages = message
40 else:
41 assert isinstance(message, (basestring, Promise)), ("%s should be a string" % repr(message))
42 self.messages = [message]
43 def __str__(self):
44 # This is needed because, without a __str__(), printing an exception
45 # instance would result in this:
46 # AttributeError: ValidationError instance has no attribute 'args'
47 # See http://www.python.org/doc/current/tut/node10.html#handling
48 return str(self.messages)
49
50class CriticalValidationError(Exception):
51 def __init__(self, message):
52 "ValidationError can be passed a string or a list."
53 if isinstance(message, list):
54 self.messages = message
55 else:
56 assert isinstance(message, (basestring, Promise)), ("'%s' should be a string" % message)
57 self.messages = [message]
58 def __str__(self):
59 return str(self.messages)
60
61def isAlphaNumeric(field_data, all_data):
62 if not alnum_re.search(field_data):
63 raise ValidationError, gettext("This value must contain only letters, numbers and underscores.")
64
65def isAlphaNumericURL(field_data, all_data):
66 if not alnumurl_re.search(field_data):
67 raise ValidationError, gettext("This value must contain only letters, numbers, underscores, dashes or slashes.")
68
69def isSlug(field_data, all_data):
70 if not slug_re.search(field_data):
71 raise ValidationError, gettext("This value must contain only letters, numbers, underscores or hyphens.")
72
73def isLowerCase(field_data, all_data):
74 if field_data.lower() != field_data:
75 raise ValidationError, gettext("Uppercase letters are not allowed here.")
76
77def isUpperCase(field_data, all_data):
78 if field_data.upper() != field_data:
79 raise ValidationError, gettext("Lowercase letters are not allowed here.")
80
81def isCommaSeparatedIntegerList(field_data, all_data):
82 for supposed_int in field_data.split(','):
83 try:
84 int(supposed_int)
85 except ValueError:
86 raise ValidationError, gettext("Enter only digits separated by commas.")
87
88def isCommaSeparatedEmailList(field_data, all_data):
89 """
90 Checks that field_data is a string of e-mail addresses separated by commas.
91 Blank field_data values will not throw a validation error, and whitespace
92 is allowed around the commas.
93 """
94 for supposed_email in field_data.split(','):
95 try:
96 isValidEmail(supposed_email.strip(), '')
97 except ValidationError:
98 raise ValidationError, gettext("Enter valid e-mail addresses separated by commas.")
99
100def isValidIPAddress4(field_data, all_data):
101 if not ip4_re.search(field_data):
102 raise ValidationError, gettext("Please enter a valid IP address.")
103
104def isNotEmpty(field_data, all_data):
105 if field_data.strip() == '':
106 raise ValidationError, gettext("Empty values are not allowed here.")
107
108def isOnlyDigits(field_data, all_data):
109 if not field_data.isdigit():
110 raise ValidationError, gettext("Non-numeric characters aren't allowed here.")
111
112def isNotOnlyDigits(field_data, all_data):
113 if field_data.isdigit():
114 raise ValidationError, gettext("This value can't be comprised solely of digits.")
115
116def isInteger(field_data, all_data):
117 # This differs from isOnlyDigits because this accepts the negative sign
118 if not integer_re.search(field_data):
119 raise ValidationError, gettext("Enter a whole number.")
120
121def isOnlyLetters(field_data, all_data):
122 if not field_data.isalpha():
123 raise ValidationError, gettext("Only alphabetical characters are allowed here.")
124
125def isValidANSIDate(field_data, all_data):
126 if not ansi_date_re.search(field_data):
127 raise ValidationError, gettext('Enter a valid date in YYYY-MM-DD format.')
128
129def isValidANSITime(field_data, all_data):
130 if not ansi_time_re.search(field_data):
131 raise ValidationError, gettext('Enter a valid time in HH:MM format.')
132
133def isValidANSIDatetime(field_data, all_data):
134 if not ansi_datetime_re.search(field_data):
135 raise ValidationError, gettext('Enter a valid date/time in YYYY-MM-DD HH:MM format.')
136
137def isValidEmail(field_data, all_data):
138 if not email_re.search(field_data):
139 raise ValidationError, gettext('Enter a valid e-mail address.')
140
141def isValidImage(field_data, all_data):
142 """
143 Checks that the file-upload field data contains a valid image (GIF, JPG,
144 PNG, possibly others -- whatever the Python Imaging Library supports).
145 """
146 from PIL import Image
147 from cStringIO import StringIO
148 try:
149 content = field_data['content']
150 except TypeError:
151 raise ValidationError, gettext("No file was submitted. Check the encoding type on the form.")
152 try:
153 Image.open(StringIO(content))
154 except IOError: # Python Imaging Library doesn't recognize it as an image
155 raise ValidationError, gettext("Upload a valid image. The file you uploaded was either not an image or a corrupted image.")
156
157def isValidImageURL(field_data, all_data):
158 uc = URLMimeTypeCheck(('image/jpeg', 'image/gif', 'image/png'))
159 try:
160 uc(field_data, all_data)
161 except URLMimeTypeCheck.InvalidContentType:
162 raise ValidationError, gettext("The URL %s does not point to a valid image.") % field_data
163
164def isValidPhone(field_data, all_data):
165 if not phone_re.search(field_data):
166 raise ValidationError, gettext('Phone numbers must be in XXX-XXX-XXXX format. "%s" is invalid.') % field_data
167
168def isValidQuicktimeVideoURL(field_data, all_data):
169 "Checks that the given URL is a video that can be played by QuickTime (qt, mpeg)"
170 uc = URLMimeTypeCheck(('video/quicktime', 'video/mpeg',))
171 try:
172 uc(field_data, all_data)
173 except URLMimeTypeCheck.InvalidContentType:
174 raise ValidationError, gettext("The URL %s does not point to a valid QuickTime video.") % field_data
175
176def isValidURL(field_data, all_data):
177 if not url_re.search(field_data):
178 raise ValidationError, gettext("A valid URL is required.")
179
180def isValidHTML(field_data, all_data):
181 import urllib, urllib2
182 try:
183 u = urllib2.urlopen('http://validator.w3.org/check', urllib.urlencode({'fragment': field_data, 'output': 'xml'}))
184 except:
185 # Validator or Internet connection is unavailable. Fail silently.
186 return
187 html_is_valid = (u.headers.get('x-w3c-validator-status', 'Invalid') == 'Valid')
188 if html_is_valid:
189 return
190 from xml.dom.minidom import parseString
191 error_messages = [e.firstChild.wholeText for e in parseString(u.read()).getElementsByTagName('messages')[0].getElementsByTagName('msg')]
192 raise ValidationError, gettext("Valid HTML is required. Specific errors are:\n%s") % "\n".join(error_messages)
193
194def isWellFormedXml(field_data, all_data):
195 from xml.dom.minidom import parseString
196 try:
197 parseString(field_data)
198 except Exception, e: # Naked except because we're not sure what will be thrown
199 raise ValidationError, gettext("Badly formed XML: %s") % str(e)
200
201def isWellFormedXmlFragment(field_data, all_data):
202 isWellFormedXml('<root>%s</root>' % field_data, all_data)
203
204def isExistingURL(field_data, all_data):
205 import urllib2
206 try:
207 u = urllib2.urlopen(field_data)
208 except ValueError:
209 raise ValidationError, gettext("Invalid URL: %s") % field_data
210 except urllib2.HTTPError, e:
211 # 401s are valid; they just mean authorization is required.
212 if e.code not in ('401',):
213 raise ValidationError, gettext("The URL %s is a broken link.") % field_data
214 except: # urllib2.URLError, httplib.InvalidURL, etc.
215 raise ValidationError, gettext("The URL %s is a broken link.") % field_data
216
217def isValidUSState(field_data, all_data):
218 "Checks that the given string is a valid two-letter U.S. state abbreviation"
219 states = ['AA', 'AE', 'AK', 'AL', 'AP', 'AR', 'AS', 'AZ', 'CA', 'CO', 'CT', 'DC', 'DE', 'FL', 'FM', 'GA', 'GU', 'HI', 'IA', 'ID', 'IL', 'IN', 'KS', 'KY', 'LA', 'MA', 'MD', 'ME', 'MH', 'MI', 'MN', 'MO', 'MP', 'MS', 'MT', 'NC', 'ND', 'NE', 'NH', 'NJ', 'NM', 'NV', 'NY', 'OH', 'OK', 'OR', 'PA', 'PR', 'PW', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VA', 'VI', 'VT', 'WA', 'WI', 'WV', 'WY']
220 if field_data.upper() not in states:
221 raise ValidationError, gettext("Enter a valid U.S. state abbreviation.")
222
223def hasNoProfanities(field_data, all_data):
224 """
225 Checks that the given string has no profanities in it. This does a simple
226 check for whether each profanity exists within the string, so 'fuck' will
227 catch 'motherfucker' as well. Raises a ValidationError such as:
228 Watch your mouth! The words "f--k" and "s--t" are not allowed here.
229 """
230 bad_words = ['asshat', 'asshead', 'asshole', 'cunt', 'fuck', 'gook', 'nigger', 'shit'] # all in lower case
231 field_data = field_data.lower() # normalize
232 words_seen = [w for w in bad_words if field_data.find(w) > -1]
233 if words_seen:
234 from django.utils.text import get_text_list
235 plural = len(words_seen) > 1
236 raise ValidationError, ngettext("Watch your mouth! The word %s is not allowed here.",
237 "Watch your mouth! The words %s are not allowed here.", plural) % \
238 get_text_list(['"%s%s%s"' % (i[0], '-'*(len(i)-2), i[-1]) for i in words_seen], 'and')
239
240class AlwaysMatchesOtherField(object):
241 def __init__(self, other_field_name, error_message=None):
242 self.other = other_field_name
243 self.error_message = error_message or lazy_inter(gettext_lazy("This field must match the '%s' field."), self.other)
244 self.always_test = True
245
246 def __call__(self, field_data, all_data):
247 if field_data != all_data[self.other]:
248 raise ValidationError, self.error_message
249
250class ValidateIfOtherFieldEquals(object):
251 def __init__(self, other_field, other_value, validator_list):
252 self.other_field, self.other_value = other_field, other_value
253 self.validator_list = validator_list
254 self.always_test = True
255
256 def __call__(self, field_data, all_data):
257 if all_data.has_key(self.other_field) and all_data[self.other_field] == self.other_value:
258 for v in self.validator_list:
259 v(field_data, all_data)
260
261class RequiredIfOtherFieldNotGiven(object):
262 def __init__(self, other_field_name, error_message=gettext_lazy("Please enter something for at least one field.")):
263 self.other, self.error_message = other_field_name, error_message
264 self.always_test = True
265
266 def __call__(self, field_data, all_data):
267 if not all_data.get(self.other, False) and not field_data:
268 raise ValidationError, self.error_message
269
270class RequiredIfOtherFieldsGiven(object):
271 def __init__(self, other_field_names, error_message=gettext_lazy("Please enter both fields or leave them both empty.")):
272 self.other, self.error_message = other_field_names, error_message
273 self.always_test = True
274
275 def __call__(self, field_data, all_data):
276 for field in self.other:
277 if all_data.get(field, False) and not field_data:
278 raise ValidationError, self.error_message
279
280class RequiredIfOtherFieldGiven(RequiredIfOtherFieldsGiven):
281 "Like RequiredIfOtherFieldsGiven, but takes a single field name instead of a list."
282 def __init__(self, other_field_name, error_message=gettext_lazy("Please enter both fields or leave them both empty.")):
283 RequiredIfOtherFieldsGiven.__init__(self, [other_field_name], error_message)
284
285class RequiredIfOtherFieldEquals(object):
286 def __init__(self, other_field, other_value, error_message=None):
287 self.other_field = other_field
288 self.other_value = other_value
289 self.error_message = error_message or lazy_inter(gettext_lazy("This field must be given if %(field)s is %(value)s"), {
290 'field': other_field, 'value': other_value})
291 self.always_test = True
292
293 def __call__(self, field_data, all_data):
294 if all_data.has_key(self.other_field) and all_data[self.other_field] == self.other_value and not field_data:
295 raise ValidationError(self.error_message)
296
297class RequiredIfOtherFieldDoesNotEqual(object):
298 def __init__(self, other_field, other_value, error_message=None):
299 self.other_field = other_field
300 self.other_value = other_value
301 self.error_message = error_message or lazy_inter(gettext_lazy("This field must be given if %(field)s is not %(value)s"), {
302 'field': other_field, 'value': other_value})
303 self.always_test = True
304
305 def __call__(self, field_data, all_data):
306 if all_data.has_key(self.other_field) and all_data[self.other_field] != self.other_value and not field_data:
307 raise ValidationError(self.error_message)
308
309class IsLessThanOtherField(object):
310 def __init__(self, other_field_name, error_message):
311 self.other, self.error_message = other_field_name, error_message
312
313 def __call__(self, field_data, all_data):
314 if field_data > all_data[self.other]:
315 raise ValidationError, self.error_message
316
317class UniqueAmongstFieldsWithPrefix(object):
318 def __init__(self, field_name, prefix, error_message):
319 self.field_name, self.prefix = field_name, prefix
320 self.error_message = error_message or gettext_lazy("Duplicate values are not allowed.")
321
322 def __call__(self, field_data, all_data):
323 for field_name, value in all_data.items():
324 if field_name != self.field_name and value == field_data:
325 raise ValidationError, self.error_message
326
327class IsAPowerOf(object):
328 """
329 >>> v = IsAPowerOf(2)
330 >>> v(4, None)
331 >>> v(8, None)
332 >>> v(16, None)
333 >>> v(17, None)
334 django.core.validators.ValidationError: ['This value must be a power of 2.']
335 """
336 def __init__(self, power_of):
337 self.power_of = power_of
338
339 def __call__(self, field_data, all_data):
340 from math import log
341 val = log(int(field_data)) / log(self.power_of)
342 if val != int(val):
343 raise ValidationError, gettext("This value must be a power of %s.") % self.power_of
344
345class IsValidFloat(object):
346 def __init__(self, max_digits, decimal_places):
347 self.max_digits, self.decimal_places = max_digits, decimal_places
348
349 def __call__(self, field_data, all_data):
350 data = str(field_data)
351 try:
352 float(data)
353 except ValueError:
354 raise ValidationError, gettext("Please enter a valid decimal number.")
355 if len(data) > (self.max_digits + 1):
356 raise ValidationError, ngettext("Please enter a valid decimal number with at most %s total digit.",
357 "Please enter a valid decimal number with at most %s total digits.", self.max_digits) % self.max_digits
358 if (not '.' in data and len(data) > (self.max_digits - self.decimal_places)) or ('.' in data and len(data) > (self.max_digits - (self.decimal_places - len(data.split('.')[1])) + 1)):
359 raise ValidationError, ngettext( "Please enter a valid decimal number with a whole part of at most %s digit.",
360 "Please enter a valid decimal number with a whole part of at most %s digits.", str(self.max_digits-self.decimal_places)) % str(self.max_digits-self.decimal_places)
361 if '.' in data and len(data.split('.')[1]) > self.decimal_places:
362 raise ValidationError, ngettext("Please enter a valid decimal number with at most %s decimal place.",
363 "Please enter a valid decimal number with at most %s decimal places.", self.decimal_places) % self.decimal_places
364
365class HasAllowableSize(object):
366 """
367 Checks that the file-upload field data is a certain size. min_size and
368 max_size are measurements in bytes.
369 """
370 def __init__(self, min_size=None, max_size=None, min_error_message=None, max_error_message=None):
371 self.min_size, self.max_size = min_size, max_size
372 self.min_error_message = min_error_message or lazy_inter(gettext_lazy("Make sure your uploaded file is at least %s bytes big."), min_size)
373 self.max_error_message = max_error_message or lazy_inter(gettext_lazy("Make sure your uploaded file is at most %s bytes big."), max_size)
374
375 def __call__(self, field_data, all_data):
376 try:
377 content = field_data['content']
378 except TypeError:
379 raise ValidationError, gettext_lazy("No file was submitted. Check the encoding type on the form.")
380 if self.min_size is not None and len(content) < self.min_size:
381 raise ValidationError, self.min_error_message
382 if self.max_size is not None and len(content) > self.max_size:
383 raise ValidationError, self.max_error_message
384
385class MatchesRegularExpression(object):
386 """
387 Checks that the field matches the given regular-expression. The regex
388 should be in string format, not already compiled.
389 """
390 def __init__(self, regexp, error_message=gettext_lazy("The format for this field is wrong.")):
391 self.regexp = re.compile(regexp)
392 self.error_message = error_message
393
394 def __call__(self, field_data, all_data):
395 if not self.regexp.search(field_data):
396 raise ValidationError(self.error_message)
397
398class AnyValidator(object):
399 """
400 This validator tries all given validators. If any one of them succeeds,
401 validation passes. If none of them succeeds, the given message is thrown
402 as a validation error. The message is rather unspecific, so it's best to
403 specify one on instantiation.
404 """
405 def __init__(self, validator_list=None, error_message=gettext_lazy("This field is invalid.")):
406 if validator_list is None: validator_list = []
407 self.validator_list = validator_list
408 self.error_message = error_message
409 for v in validator_list:
410 if hasattr(v, 'always_test'):
411 self.always_test = True
412
413 def __call__(self, field_data, all_data):
414 for v in self.validator_list:
415 try:
416 v(field_data, all_data)
417 return
418 except ValidationError, e:
419 pass
420 raise ValidationError(self.error_message)
421
422class URLMimeTypeCheck(object):
423 "Checks that the provided URL points to a document with a listed mime type"
424 class CouldNotRetrieve(ValidationError):
425 pass
426 class InvalidContentType(ValidationError):
427 pass
428
429 def __init__(self, mime_type_list):
430 self.mime_type_list = mime_type_list
431
432 def __call__(self, field_data, all_data):
433 import urllib2
434 try:
435 isValidURL(field_data, all_data)
436 except ValidationError:
437 raise
438 try:
439 info = urllib2.urlopen(field_data).info()
440 except (urllib2.HTTPError, urllib2.URLError):
441 raise URLMimeTypeCheck.CouldNotRetrieve, gettext("Could not retrieve anything from %s.") % field_data
442 content_type = info['content-type']
443 if content_type not in self.mime_type_list:
444 raise URLMimeTypeCheck.InvalidContentType, gettext("The URL %(url)s returned the invalid Content-Type header '%(contenttype)s'.") % {
445 'url': field_data, 'contenttype': content_type}
446
447class RelaxNGCompact(object):
448 "Validate against a Relax NG compact schema"
449 def __init__(self, schema_path, additional_root_element=None):
450 self.schema_path = schema_path
451 self.additional_root_element = additional_root_element
452
453 def __call__(self, field_data, all_data):
454 import os, tempfile
455 if self.additional_root_element:
456 field_data = '<%(are)s>%(data)s\n</%(are)s>' % {
457 'are': self.additional_root_element,
458 'data': field_data
459 }
460 filename = tempfile.mktemp() # Insecure, but nothing else worked
461 fp = open(filename, 'w')
462 fp.write(field_data)
463 fp.close()
464 if not os.path.exists(settings.JING_PATH):
465 raise Exception, "%s not found!" % settings.JING_PATH
466 p = os.popen('%s -c %s %s' % (settings.JING_PATH, self.schema_path, filename))
467 errors = [line.strip() for line in p.readlines()]
468 p.close()
469 os.unlink(filename)
470 display_errors = []
471 lines = field_data.split('\n')
472 for error in errors:
473 ignored, line, level, message = error.split(':', 3)
474 # Scrape the Jing error messages to reword them more nicely.
475 m = re.search(r'Expected "(.*?)" to terminate element starting on line (\d+)', message)
476 if m:
477 display_errors.append(_('Please close the unclosed %(tag)s tag from line %(line)s. (Line starts with "%(start)s".)') % \
478 {'tag':m.group(1).replace('/', ''), 'line':m.group(2), 'start':lines[int(m.group(2)) - 1][:30]})
479 continue
480 if message.strip() == 'text not allowed here':
481 display_errors.append(_('Some text starting on line %(line)s is not allowed in that context. (Line starts with "%(start)s".)') % \
482 {'line':line, 'start':lines[int(line) - 1][:30]})
483 continue
484 m = re.search(r'\s*attribute "(.*?)" not allowed at this point; ignored', message)
485 if m:
486 display_errors.append(_('"%(attr)s" on line %(line)s is an invalid attribute. (Line starts with "%(start)s".)') % \
487 {'attr':m.group(1), 'line':line, 'start':lines[int(line) - 1][:30]})
488 continue
489 m = re.search(r'\s*unknown element "(.*?)"', message)
490 if m:
491 display_errors.append(_('"<%(tag)s>" on line %(line)s is an invalid tag. (Line starts with "%(start)s".)') % \
492 {'tag':m.group(1), 'line':line, 'start':lines[int(line) - 1][:30]})
493 continue
494 if message.strip() == 'required attributes missing':
495 display_errors.append(_('A tag on line %(line)s is missing one or more required attributes. (Line starts with "%(start)s".)') % \
496 {'line':line, 'start':lines[int(line) - 1][:30]})
497 continue
498 m = re.search(r'\s*bad value for attribute "(.*?)"', message)
499 if m:
500 display_errors.append(_('The "%(attr)s" attribute on line %(line)s has an invalid value. (Line starts with "%(start)s".)') % \
501 {'attr':m.group(1), 'line':line, 'start':lines[int(line) - 1][:30]})
502 continue
503 # Failing all those checks, use the default error message.
504 display_error = 'Line %s: %s [%s]' % (line, message, level.strip())
505 display_errors.append(display_error)
506 if len(display_errors) > 0:
507 raise ValidationError, display_errors
Back to Top