# Author: Michael DiBernardo (mikedebo@gmail.com) from django import newforms as forms from django.newforms import ValidationError import datetime import re class KungfuTimeField(forms.Field): """ Extension to Django's time fields that parses a much larger range of times without explicitly needing to specify all the time formats yourself. """ # Matches any string with a 24-hourish format (sans AM/PM) but puts no # limits on the size of the numbers (e.g. 64:99 is OK.) _24_HOUR_PATTERN_STRING = r'^\s*(?P\d\d?)\s*:?\s*(?P\d\d)?\s*' # Matches any string with a 12-hourish format (with AM/PM) but puts no # limits on the size of the numbers (e.g. 64:99pm is OK.) _TIME_PATTERN_STRING = _24_HOUR_PATTERN_STRING + \ r'((?P[AaPp])\s*\.?\s*[Mm]?\s*\.?\s*)?$' # Matcher for our time pattern. _TIME_PATTERN = re.compile(_TIME_PATTERN_STRING) # Validation error messages. _ERROR_MESSAGES = { 'invalid' : u'Enter a valid time.', } def __init__(self, *args, **kwargs): """ Create a new KungFuTimeField with the mojo of a thousand TimeFields. """ super(KungfuTimeField, self).__init__(*args, **kwargs) def clean(self, value): """ Parses datetime from the given value. If it can't figure it out, throws a ValidationError. """ super(KungfuTimeField, self).clean(value) if not value: return None if isinstance(value, datetime.time): return value cleaned = self._parse_time(value) return cleaned def _parse_time(self, value): """ Tries to recognize a time using our hefty regexp. If it doesn't match the regexp, throws a validation error. If it DOES match but the resulting numbers are out of range (e.g. an hour of 99), will also throw a ValidationError. """ match = self._TIME_PATTERN.match(value) if not match: raise ValidationError(self._ERROR_MESSAGES['invalid']) # Hour has to be there because it's required in the regexp. Set the # minute to 0 for now. We dunno if there's AM/PM or not. (hour, minute, ampm) = (int(match.group('hour')), 0, match.group('ampm')) # Let's see if the user typed a minute. try: # Raises TypeError if group fetch returns None. minute = int(match.group('minute')) except TypeError: pass if ampm: hour = self._handle_twelve_hour_time(hour, minute, ampm) # If the numbers are out of range, we'll find out here. try: return datetime.time(hour, minute) except ValueError: raise ValidationError(self._ERROR_MESSAGES['invalid']) # I don't expect any other problems, but if there are, they'll # propagate. def _handle_twelve_hour_time(self, hour, minute, ampm): """ Detect 24 hour time with an am/pm (e.g. 18:30pm, 0:30am) and do the necessary transform for converting pm times to 24 hour times. """ if hour < 1 or hour > 12: raise ValidationError(self._ERROR_MESSAGES['invalid']) elif ampm.lower() == "a" and hour == 12: return 0 elif ampm.lower() == "p" and 1 <= hour and hour <= 11: return hour + 12 else: return hour """ Tests that our extension to the Django time field can parse a wide variety of time formats. """ # Author: Michael DiBernardo (mikedebo@gmail.com) from formhelpers.fields import KungfuTimeField from django.newforms import ValidationError import datetime as dt import unittest class TestTimeParsing(unittest.TestCase): def setUp(self): self.field = KungfuTimeField() def test24HourFormats(self): """ Tests a variety of 24 hour formats. """ twofour_hour_tests = ( ("8:30", dt.time(8, 30)), ("14:30", dt.time(14, 30)), ("8", dt.time(8)), ("16", dt.time(16)), ("1742", dt.time(17, 42)), ("856", dt.time(8, 56)), ("101", dt.time(1, 01)), ("1 01", dt.time(1, 01)), ("13 01", dt.time(13, 01)), ("01 01", dt.time(1, 1)), (" 13 01 ", dt.time(13, 01)), ) for (input, expected) in twofour_hour_tests: self.assertTimeEquals(input, expected) def test12HourFormats(self): """ Tests a variety of 12 hour formats. """ twelve_hour_tests = ( ("12:30pm", dt.time(12, 30)), ("12:30PM", dt.time(12, 30)), ("12:30P.m.", dt.time(12, 30)), ("12:30 pm ", dt.time(12, 30)), ("12:30 P m", dt.time(12, 30)), ("12:30 p . M .", dt.time(12, 30)), ("12:30p", dt.time(12, 30)), ("12:30 p", dt.time(12, 30)), ("12 30pm", dt.time(12, 30)), ("12 30 pm", dt.time(12, 30)), ("1230 p m", dt.time(12, 30)), (" 1230 p m", dt.time(12, 30)), ("12 30 p . m .", dt.time(12, 30)), ("1:30am", dt.time(1, 30)), ("1:30 am ", dt.time(1, 30)), ("1:30 a m", dt.time(1, 30)), ("1:30 a . m .", dt.time(1, 30)), ("1:30a", dt.time(1, 30)), ("1:30 a", dt.time(1, 30)), ("1 30am", dt.time(1, 30)), (" 1 30 am", dt.time(1, 30)), ("130 a m", dt.time(1, 30)), ("3:30 p m", dt.time(15, 30)), (" 1 30 a . m ", dt.time(1, 30)), ) for (input, expected) in twelve_hour_tests: self.assertTimeEquals(input, expected) def testBadTimes(self): """ Tests a variety of times that should bork and die, not necessarily in that order. """ bad_inputs = ( "", " aa ", "12345", "1340am", "030pm", "25:23", "24:10", "this ain't nothing like a time", "-2:30", ) for bad_input in bad_inputs: try: self.field.clean(bad_input) self.fail("Bad input %s validated." % bad_input) except AssertionError, e: raise e except ValidationError, e: pass except Exception, e: self.fail("Validator threw unexpected exception %s" % str(e)) def assertTimeEquals(self, timestring, expected): """ Try to parse a time, and if it throws an exception, fail. """ try: actual = self.field.clean(timestring) self.assertEquals(actual, expected, "String %s did not parse to expected datetime %s: Got %s" % (timestring, str(expected), str(actual)) ) except AssertionError, e: # Propagate assertion failures. raise e except ValidationError, e: self.fail("String %s did not validate." % timestring) except Exception, e: self.fail("String %s caused unexpected exception: %s" % (timestring, str(e)))