diff --git a/timedelta/VERSION b/timedelta/VERSION new file mode 100644 index 000000000..f38fc5393 --- /dev/null +++ b/timedelta/VERSION @@ -0,0 +1 @@ +0.7.3 diff --git a/timedelta/__init__.py b/timedelta/__init__.py new file mode 100644 index 000000000..e7b26d09c --- /dev/null +++ b/timedelta/__init__.py @@ -0,0 +1,19 @@ +import os + +__version__ = open(os.path.join(os.path.dirname(__file__), "VERSION")).read().strip() + +try: + from django.core.exceptions import ImproperlyConfigured +except ImportError: + ImproperlyConfigured = ImportError + +try: + from .fields import TimedeltaField + from .helpers import ( + divide, multiply, modulo, + parse, nice_repr, + percentage, decimal_percentage, + total_seconds + ) +except (ImportError, ImproperlyConfigured): + pass \ No newline at end of file diff --git a/timedelta/fields.py b/timedelta/fields.py new file mode 100644 index 000000000..fd9e0c36b --- /dev/null +++ b/timedelta/fields.py @@ -0,0 +1,120 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.utils import six + +from collections import defaultdict +import datetime +import six + +from .helpers import parse +from .forms import TimedeltaFormField + +# TODO: Figure out why django admin thinks fields of this type have changed every time an object is saved. + +# Define the different column types that different databases can use. +COLUMN_TYPES = defaultdict(lambda:"char(20)") +COLUMN_TYPES["django.db.backends.postgresql_psycopg2"] = "interval" +COLUMN_TYPES["django.contrib.gis.db.backends.postgis"] = "interval" + +class TimedeltaField(six.with_metaclass(models.SubfieldBase, models.Field)): + """ + Store a datetime.timedelta as an INTERVAL in postgres, or a + CHAR(20) in other database backends. + """ + _south_introspects = True + + description = "A datetime.timedelta object" + + def __init__(self, *args, **kwargs): + self._min_value = kwargs.pop('min_value', None) + + if isinstance(self._min_value, (int, float)): + self._min_value = datetime.timedelta(seconds=self._min_value) + + self._max_value = kwargs.pop('max_value', None) + + if isinstance(self._max_value, (int, float)): + self._max_value = datetime.timedelta(seconds=self._max_value) + + super(TimedeltaField, self).__init__(*args, **kwargs) + + def to_python(self, value): + if (value is None) or isinstance(value, datetime.timedelta): + return value + if isinstance(value, (int, float)): + return datetime.timedelta(seconds=value) + if isinstance(value, six.string_types) and value.replace('.','0').isdigit(): + return datetime.timedelta(seconds=float(value)) + if value == "": + if self.null: + return None + else: + return datetime.timedelta(0) + return parse(value) + + def get_prep_value(self, value): + if self.null and value == "": + return None + if (value is None) or isinstance(value, six.string_types): + return value + return str(value).replace(',', '') + + def get_db_prep_value(self, value, connection=None, prepared=None): + return self.get_prep_value(value) + + def formfield(self, *args, **kwargs): + defaults = {'form_class':TimedeltaFormField} + defaults.update(kwargs) + return super(TimedeltaField, self).formfield(*args, **defaults) + + def validate(self, value, model_instance): + super(TimedeltaField, self).validate(value, model_instance) + if self._min_value is not None: + if self._min_value > value: + raise ValidationError('Less than minimum allowed value') + if self._max_value is not None: + if self._max_value < value: + raise ValidationError('More than maximum allowed value') + + def value_to_string(self, obj): + value = self._get_val_from_obj(obj) + return unicode(value) + + def get_default(self): + """ + Needed to rewrite this, as the parent class turns this value into a + unicode string. That sux pretty deep. + """ + if self.has_default(): + if callable(self.default): + return self.default() + return self.get_prep_value(self.default) + if not self.empty_strings_allowed or (self.null): + return None + return "" + + def db_type(self, connection): + return COLUMN_TYPES[connection.settings_dict['ENGINE']] + + def deconstruct(self): + """ + Break down this field into arguments that can be used to reproduce it + with Django migrations. + + The thing to to note here is that currently the migration file writer + can't serialize timedelta objects so we convert them to a float + representation (in seconds) that we can later interpret as a timedelta. + """ + + name, path, args, kwargs = super(TimedeltaField, self).deconstruct() + + if isinstance(self._min_value, datetime.timedelta): + kwargs['min_value'] = self._min_value.total_seconds() + + if isinstance(self._max_value, datetime.timedelta): + kwargs['max_value'] = self._max_value.total_seconds() + + if isinstance(kwargs.get('default'), datetime.timedelta): + kwargs['default'] = kwargs['default'].total_seconds() + + return name, path, args, kwargs diff --git a/timedelta/forms.py b/timedelta/forms.py new file mode 100644 index 000000000..3ec1f11c1 --- /dev/null +++ b/timedelta/forms.py @@ -0,0 +1,99 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ +from django.utils import six + +import datetime +from collections import defaultdict + +from .widgets import TimedeltaWidget +from .helpers import parse + +class TimedeltaFormField(forms.Field): + + default_error_messages = { + 'invalid':_('Enter a valid time span: e.g. "3 days, 4 hours, 2 minutes"') + } + + def __init__(self, *args, **kwargs): + defaults = {'widget':TimedeltaWidget} + defaults.update(kwargs) + super(TimedeltaFormField, self).__init__(*args, **defaults) + + def clean(self, value): + """ + This doesn't really need to be here: it should be tested in + parse()... + + >>> t = TimedeltaFormField() + >>> t.clean('1 day') + datetime.timedelta(1) + >>> t.clean('1 day, 0:00:00') + datetime.timedelta(1) + >>> t.clean('1 day, 8:42:42.342') + datetime.timedelta(1, 31362, 342000) + >>> t.clean('3 days, 8:42:42.342161') + datetime.timedelta(3, 31362, 342161) + >>> try: + ... t.clean('3 days, 8:42:42.3.42161') + ... except forms.ValidationError as arg: + ... six.print_(arg.messages[0]) + Enter a valid time span: e.g. "3 days, 4 hours, 2 minutes" + >>> t.clean('5 day, 8:42:42') + datetime.timedelta(5, 31362) + >>> t.clean('1 days') + datetime.timedelta(1) + >>> t.clean('1 second') + datetime.timedelta(0, 1) + >>> t.clean('1 sec') + datetime.timedelta(0, 1) + >>> t.clean('10 seconds') + datetime.timedelta(0, 10) + >>> t.clean('30 seconds') + datetime.timedelta(0, 30) + >>> t.clean('1 minute, 30 seconds') + datetime.timedelta(0, 90) + >>> t.clean('2.5 minutes') + datetime.timedelta(0, 150) + >>> t.clean('2 minutes, 30 seconds') + datetime.timedelta(0, 150) + >>> t.clean('.5 hours') + datetime.timedelta(0, 1800) + >>> t.clean('30 minutes') + datetime.timedelta(0, 1800) + >>> t.clean('1 hour') + datetime.timedelta(0, 3600) + >>> t.clean('5.5 hours') + datetime.timedelta(0, 19800) + >>> t.clean('1 day, 1 hour, 30 mins') + datetime.timedelta(1, 5400) + >>> t.clean('8 min') + datetime.timedelta(0, 480) + >>> t.clean('3 days, 12 hours') + datetime.timedelta(3, 43200) + >>> t.clean('3.5 day') + datetime.timedelta(3, 43200) + >>> t.clean('1 week') + datetime.timedelta(7) + >>> t.clean('2 weeks, 2 days') + datetime.timedelta(16) + >>> try: + ... t.clean(six.u('2 we\xe8k, 2 days')) + ... except forms.ValidationError as arg: + ... six.print_(arg.messages[0]) + Enter a valid time span: e.g. "3 days, 4 hours, 2 minutes" + """ + + super(TimedeltaFormField, self).clean(value) + if value == '' and not self.required: + return '' + try: + return parse(value) + except TypeError: + raise forms.ValidationError(self.error_messages['invalid']) + +class TimedeltaChoicesField(TimedeltaFormField): + def __init__(self, *args, **kwargs): + choices = kwargs.pop('choices') + defaults = {'widget':forms.Select(choices=choices)} + defaults.update(kwargs) + super(TimedeltaChoicesField, self).__init__(*args, **defaults) diff --git a/timedelta/helpers.py b/timedelta/helpers.py new file mode 100644 index 000000000..dc3a40d5c --- /dev/null +++ b/timedelta/helpers.py @@ -0,0 +1,554 @@ +from __future__ import division + +import re +import datetime +from decimal import Decimal + +from django.utils import six + +STRFDATETIME = re.compile('([dgGhHis])') +STRFDATETIME_REPL = lambda x: '%%(%s)s' % x.group() + +def nice_repr(timedelta, display="long", sep=", "): + """ + Turns a datetime.timedelta object into a nice string repr. + + display can be "sql", "iso8601", "minimal", "short" or "long" [default]. + + >>> from datetime import timedelta as td + >>> nice_repr(td(days=1, hours=2, minutes=3, seconds=4)) + '1 day, 2 hours, 3 minutes, 4 seconds' + >>> nice_repr(td(days=1, seconds=1), "minimal") + '1d, 1s' + >>> nice_repr(datetime.timedelta(days=1)) + '1 day' + >>> nice_repr(datetime.timedelta(days=0)) + '0 seconds' + >>> nice_repr(datetime.timedelta(seconds=1)) + '1 second' + >>> nice_repr(datetime.timedelta(seconds=10)) + '10 seconds' + >>> nice_repr(datetime.timedelta(seconds=30)) + '30 seconds' + >>> nice_repr(datetime.timedelta(seconds=60)) + '1 minute' + >>> nice_repr(datetime.timedelta(seconds=150)) + '2 minutes, 30 seconds' + >>> nice_repr(datetime.timedelta(seconds=1800)) + '30 minutes' + >>> nice_repr(datetime.timedelta(seconds=3600)) + '1 hour' + >>> nice_repr(datetime.timedelta(seconds=3601)) + '1 hour, 1 second' + >>> nice_repr(datetime.timedelta(seconds=19800)) + '5 hours, 30 minutes' + >>> nice_repr(datetime.timedelta(seconds=91800)) + '1 day, 1 hour, 30 minutes' + >>> nice_repr(datetime.timedelta(seconds=302400)) + '3 days, 12 hours' + + Tests for handling zero: + >>> nice_repr(td(seconds=0), 'minimal') + '0s' + >>> nice_repr(td(seconds=0), 'short') + '0 sec' + >>> nice_repr(td(seconds=0), 'long') + '0 seconds' + """ + + assert isinstance(timedelta, datetime.timedelta), "First argument must be a timedelta." + + result = [] + + weeks = int(timedelta.days / 7) + days = timedelta.days % 7 + hours = int(timedelta.seconds / 3600) + minutes = int((timedelta.seconds % 3600) / 60) + seconds = timedelta.seconds % 60 + + if display == "sql": + days += weeks * 7 + return "%i %02i:%02i:%02i" % (days, hours, minutes, seconds) + elif display == "iso8601": + return iso8601_repr(timedelta) + elif display == 'minimal': + words = ["w", "d", "h", "m", "s"] + elif display == 'short': + words = [" wks", " days", " hrs", " min", " sec"] + elif display == 'long': + words = [" weeks", " days", " hours", " minutes", " seconds"] + else: + # Use django template-style formatting. + # Valid values are: + # d,g,G,h,H,i,s + return STRFDATETIME.sub(STRFDATETIME_REPL, display) % { + 'd': days, + 'g': hours, + 'G': hours if hours > 9 else '0%s' % hours, + 'h': hours, + 'H': hours if hours > 9 else '0%s' % hours, + 'i': minutes if minutes > 9 else '0%s' % minutes, + 's': seconds if seconds > 9 else '0%s' % seconds + } + + values = [weeks, days, hours, minutes, seconds] + + for i in range(len(values)): + if values[i]: + if values[i] == 1 and len(words[i]) > 1: + result.append("%i%s" % (values[i], words[i].rstrip('s'))) + else: + result.append("%i%s" % (values[i], words[i])) + + # values with less than one second, which are considered zeroes + if len(result) == 0: + # display as 0 of the smallest unit + result.append('0%s' % (words[-1])) + + return sep.join(result) + + +def iso8601_repr(timedelta, format=None): + """ + Represent a timedelta as an ISO8601 duration. + http://en.wikipedia.org/wiki/ISO_8601#Durations + + >>> from datetime import timedelta as td + >>> iso8601_repr(td(days=1, hours=2, minutes=3, seconds=4)) + 'P1DT2H3M4S' + + >>> iso8601_repr(td(hours=1, minutes=10, seconds=20), 'alt') + 'PT01:10:20' + """ + years = int(timedelta.days / 365) + weeks = int((timedelta.days % 365) / 7) + days = timedelta.days % 7 + + hours = int(timedelta.seconds / 3600) + minutes = int((timedelta.seconds % 3600) / 60) + seconds = timedelta.seconds % 60 + + if format == 'alt': + if years or weeks or days: + raise ValueError('Does not support alt format for durations > 1 day') + return 'PT{0:02d}:{1:02d}:{2:02d}'.format(hours, minutes, seconds) + + formatting = ( + ('P', ( + ('Y', years), + ('W', weeks), + ('D', days), + )), + ('T', ( + ('H', hours), + ('M', minutes), + ('S', seconds), + )), + ) + + result = [] + for category, subcats in formatting: + result += category + for format, value in subcats: + if value: + result.append('%d%c' % (value, format)) + if result[-1] == 'T': + result = result[:-1] + + return "".join(result) + +def parse(string): + """ + Parse a string into a timedelta object. + + >>> parse("1 day") + datetime.timedelta(1) + >>> parse("2 days") + datetime.timedelta(2) + >>> parse("1 d") + datetime.timedelta(1) + >>> parse("1 hour") + datetime.timedelta(0, 3600) + >>> parse("1 hours") + datetime.timedelta(0, 3600) + >>> parse("1 hr") + datetime.timedelta(0, 3600) + >>> parse("1 hrs") + datetime.timedelta(0, 3600) + >>> parse("1h") + datetime.timedelta(0, 3600) + >>> parse("1wk") + datetime.timedelta(7) + >>> parse("1 week") + datetime.timedelta(7) + >>> parse("1 weeks") + datetime.timedelta(7) + >>> parse("2 wks") + datetime.timedelta(14) + >>> parse("1 sec") + datetime.timedelta(0, 1) + >>> parse("1 secs") + datetime.timedelta(0, 1) + >>> parse("1 s") + datetime.timedelta(0, 1) + >>> parse("1 second") + datetime.timedelta(0, 1) + >>> parse("1 seconds") + datetime.timedelta(0, 1) + >>> parse("1 minute") + datetime.timedelta(0, 60) + >>> parse("1 min") + datetime.timedelta(0, 60) + >>> parse("1 m") + datetime.timedelta(0, 60) + >>> parse("1 minutes") + datetime.timedelta(0, 60) + >>> parse("1 mins") + datetime.timedelta(0, 60) + >>> parse("2 ws") + Traceback (most recent call last): + ... + TypeError: '2 ws' is not a valid time interval + >>> parse("2 ds") + Traceback (most recent call last): + ... + TypeError: '2 ds' is not a valid time interval + >>> parse("2 hs") + Traceback (most recent call last): + ... + TypeError: '2 hs' is not a valid time interval + >>> parse("2 ms") + Traceback (most recent call last): + ... + TypeError: '2 ms' is not a valid time interval + >>> parse("2 ss") + Traceback (most recent call last): + ... + TypeError: '2 ss' is not a valid time interval + >>> parse("") + Traceback (most recent call last): + ... + TypeError: '' is not a valid time interval + >>> parse("1.5 days") + datetime.timedelta(1, 43200) + >>> parse("3 weeks") + datetime.timedelta(21) + >>> parse("4.2 hours") + datetime.timedelta(0, 15120) + >>> parse(".5 hours") + datetime.timedelta(0, 1800) + >>> parse(" hours") + Traceback (most recent call last): + ... + TypeError: 'hours' is not a valid time interval + >>> parse("1 hour, 5 mins") + datetime.timedelta(0, 3900) + + >>> parse("-2 days") + datetime.timedelta(-2) + >>> parse("-1 day 0:00:01") + datetime.timedelta(-1, 1) + >>> parse("-1 day, -1:01:01") + datetime.timedelta(-2, 82739) + >>> parse("-1 weeks, 2 days, -3 hours, 4 minutes, -5 seconds") + datetime.timedelta(-5, 11045) + + >>> parse("0 seconds") + datetime.timedelta(0) + >>> parse("0 days") + datetime.timedelta(0) + >>> parse("0 weeks") + datetime.timedelta(0) + + >>> zero = datetime.timedelta(0) + >>> parse(nice_repr(zero)) + datetime.timedelta(0) + >>> parse(nice_repr(zero, 'minimal')) + datetime.timedelta(0) + >>> parse(nice_repr(zero, 'short')) + datetime.timedelta(0) + >>> parse(' 50 days 00:00:00 ') + datetime.timedelta(50) + """ + string = string.strip() + + if string == "": + raise TypeError("'%s' is not a valid time interval" % string) + # This is the format we get from sometimes Postgres, sqlite, + # and from serialization + d = re.match(r'^((?P[-+]?\d+) days?,? )?(?P[-+]?)(?P\d+):' + r'(?P\d+)(:(?P\d+(\.\d+)?))?$', + six.text_type(string)) + if d: + d = d.groupdict(0) + if d['sign'] == '-': + for k in 'hours', 'minutes', 'seconds': + d[k] = '-' + d[k] + d.pop('sign', None) + else: + # This is the more flexible format + d = re.match( + r'^((?P-?((\d*\.\d+)|\d+))\W*w((ee)?(k(s)?)?)(,)?\W*)?' + r'((?P-?((\d*\.\d+)|\d+))\W*d(ay(s)?)?(,)?\W*)?' + r'((?P-?((\d*\.\d+)|\d+))\W*h(ou)?(r(s)?)?(,)?\W*)?' + r'((?P-?((\d*\.\d+)|\d+))\W*m(in(ute)?(s)?)?(,)?\W*)?' + r'((?P-?((\d*\.\d+)|\d+))\W*s(ec(ond)?(s)?)?)?\W*$', + six.text_type(string)) + if not d: + raise TypeError("'%s' is not a valid time interval" % string) + d = d.groupdict(0) + + return datetime.timedelta(**dict(( (k, float(v)) for k,v in d.items()))) + + +def divide(obj1, obj2, as_float=False): + """ + Allows for the division of timedeltas by other timedeltas, or by + floats/Decimals + + >>> from datetime import timedelta as td + >>> divide(td(1), td(1)) + 1 + >>> divide(td(2), td(1)) + 2 + >>> divide(td(32), 16) + datetime.timedelta(2) + >>> divide(datetime.timedelta(1), datetime.timedelta(hours=6)) + 4 + >>> divide(datetime.timedelta(2), datetime.timedelta(3)) + 0 + >>> divide(datetime.timedelta(8), datetime.timedelta(3), as_float=True) + 2.6666666666666665 + >>> divide(datetime.timedelta(8), 2.0) + datetime.timedelta(4) + >>> divide(datetime.timedelta(8), 2, as_float=True) + Traceback (most recent call last): + ... + AssertionError: as_float=True is inappropriate when dividing timedelta by a number. + + """ + assert isinstance(obj1, datetime.timedelta), "First argument must be a timedelta." + assert isinstance(obj2, (datetime.timedelta, int, float, Decimal)), "Second argument must be a timedelta or number" + + sec1 = obj1.days * 86400 + obj1.seconds + if isinstance(obj2, datetime.timedelta): + sec2 = obj2.days * 86400 + obj2.seconds + value = sec1 / sec2 + if as_float: + return value + return int(value) + else: + if as_float: + assert None, "as_float=True is inappropriate when dividing timedelta by a number." + secs = sec1 / obj2 + if isinstance(secs, Decimal): + secs = float(secs) + return datetime.timedelta(seconds=secs) + +def modulo(obj1, obj2): + """ + Allows for remainder division of timedelta by timedelta or integer. + + >>> from datetime import timedelta as td + >>> modulo(td(5), td(2)) + datetime.timedelta(1) + >>> modulo(td(6), td(3)) + datetime.timedelta(0) + >>> modulo(td(15), 4 * 3600 * 24) + datetime.timedelta(3) + + >>> modulo(5, td(1)) + Traceback (most recent call last): + ... + AssertionError: First argument must be a timedelta. + >>> modulo(td(1), 2.8) + Traceback (most recent call last): + ... + AssertionError: Second argument must be a timedelta or int. + """ + assert isinstance(obj1, datetime.timedelta), "First argument must be a timedelta." + assert isinstance(obj2, (datetime.timedelta, int)), "Second argument must be a timedelta or int." + + sec1 = obj1.days * 86400 + obj1.seconds + if isinstance(obj2, datetime.timedelta): + sec2 = obj2.days * 86400 + obj2.seconds + return datetime.timedelta(seconds=sec1 % sec2) + else: + return datetime.timedelta(seconds=(sec1 % obj2)) + +def percentage(obj1, obj2): + """ + What percentage of obj2 is obj1? We want the answer as a float. + >>> percentage(datetime.timedelta(4), datetime.timedelta(2)) + 200.0 + >>> percentage(datetime.timedelta(2), datetime.timedelta(4)) + 50.0 + """ + assert isinstance(obj1, datetime.timedelta), "First argument must be a timedelta." + assert isinstance(obj2, datetime.timedelta), "Second argument must be a timedelta." + + return divide(obj1 * 100, obj2, as_float=True) + +def decimal_percentage(obj1, obj2): + """ + >>> decimal_percentage(datetime.timedelta(4), datetime.timedelta(2)) + Decimal('200.0') + >>> decimal_percentage(datetime.timedelta(2), datetime.timedelta(4)) + Decimal('50.0') + """ + return Decimal(str(percentage(obj1, obj2))) + + +def multiply(obj, val): + """ + Allows for the multiplication of timedeltas by float values. + >>> multiply(datetime.timedelta(seconds=20), 1.5) + datetime.timedelta(0, 30) + >>> multiply(datetime.timedelta(1), 2.5) + datetime.timedelta(2, 43200) + >>> multiply(datetime.timedelta(1), 3) + datetime.timedelta(3) + >>> multiply(datetime.timedelta(1), Decimal("5.5")) + datetime.timedelta(5, 43200) + >>> multiply(datetime.date.today(), 2.5) + Traceback (most recent call last): + ... + AssertionError: First argument must be a timedelta. + >>> multiply(datetime.timedelta(1), "2") + Traceback (most recent call last): + ... + AssertionError: Second argument must be a number. + """ + + assert isinstance(obj, datetime.timedelta), "First argument must be a timedelta." + assert isinstance(val, (int, float, Decimal)), "Second argument must be a number." + + sec = obj.days * 86400 + obj.seconds + sec *= val + if isinstance(sec, Decimal): + sec = float(sec) + return datetime.timedelta(seconds=sec) + + +def round_to_nearest(obj, timedelta): + """ + The obj is rounded to the nearest whole number of timedeltas. + + obj can be a timedelta, datetime or time object. + + >>> round_to_nearest(datetime.datetime(2012, 1, 1, 9, 43), datetime.timedelta(1)) + datetime.datetime(2012, 1, 1, 0, 0) + >>> round_to_nearest(datetime.datetime(2012, 1, 1, 9, 43), datetime.timedelta(hours=1)) + datetime.datetime(2012, 1, 1, 10, 0) + >>> round_to_nearest(datetime.datetime(2012, 1, 1, 9, 43), datetime.timedelta(minutes=15)) + datetime.datetime(2012, 1, 1, 9, 45) + >>> round_to_nearest(datetime.datetime(2012, 1, 1, 9, 43), datetime.timedelta(minutes=1)) + datetime.datetime(2012, 1, 1, 9, 43) + + >>> td = datetime.timedelta(minutes=30) + >>> round_to_nearest(datetime.timedelta(minutes=0), td) + datetime.timedelta(0) + >>> round_to_nearest(datetime.timedelta(minutes=14), td) + datetime.timedelta(0) + >>> round_to_nearest(datetime.timedelta(minutes=15), td) + datetime.timedelta(0, 1800) + >>> round_to_nearest(datetime.timedelta(minutes=29), td) + datetime.timedelta(0, 1800) + >>> round_to_nearest(datetime.timedelta(minutes=30), td) + datetime.timedelta(0, 1800) + >>> round_to_nearest(datetime.timedelta(minutes=42), td) + datetime.timedelta(0, 1800) + >>> round_to_nearest(datetime.timedelta(hours=7, minutes=22), td) + datetime.timedelta(0, 27000) + + >>> td = datetime.timedelta(minutes=15) + >>> round_to_nearest(datetime.timedelta(minutes=0), td) + datetime.timedelta(0) + >>> round_to_nearest(datetime.timedelta(minutes=14), td) + datetime.timedelta(0, 900) + >>> round_to_nearest(datetime.timedelta(minutes=15), td) + datetime.timedelta(0, 900) + >>> round_to_nearest(datetime.timedelta(minutes=29), td) + datetime.timedelta(0, 1800) + >>> round_to_nearest(datetime.timedelta(minutes=30), td) + datetime.timedelta(0, 1800) + >>> round_to_nearest(datetime.timedelta(minutes=42), td) + datetime.timedelta(0, 2700) + >>> round_to_nearest(datetime.timedelta(hours=7, minutes=22), td) + datetime.timedelta(0, 26100) + + >>> td = datetime.timedelta(minutes=30) + >>> round_to_nearest(datetime.datetime(2010,1,1,9,22), td) + datetime.datetime(2010, 1, 1, 9, 30) + >>> round_to_nearest(datetime.datetime(2010,1,1,9,32), td) + datetime.datetime(2010, 1, 1, 9, 30) + >>> round_to_nearest(datetime.datetime(2010,1,1,9,42), td) + datetime.datetime(2010, 1, 1, 9, 30) + + >>> round_to_nearest(datetime.time(0,20), td) + datetime.time(0, 30) + + TODO: test with tzinfo (non-naive) datetimes/times. + """ + + assert isinstance(obj, (datetime.datetime, datetime.timedelta, datetime.time)), "First argument must be datetime, time or timedelta." + assert isinstance(timedelta, datetime.timedelta), "Second argument must be a timedelta." + + time_only = False + if isinstance(obj, datetime.timedelta): + counter = datetime.timedelta(0) + elif isinstance(obj, datetime.datetime): + counter = datetime.datetime.combine(obj.date(), datetime.time(0, tzinfo=obj.tzinfo)) + elif isinstance(obj, datetime.time): + counter = datetime.datetime.combine(datetime.date.today(), datetime.time(0, tzinfo=obj.tzinfo)) + obj = datetime.datetime.combine(datetime.date.today(), obj) + time_only = True + + diff = abs(obj - counter) + while counter < obj: + old_diff = diff + counter += timedelta + diff = abs(obj - counter) + + if counter == obj: + result = obj + elif diff <= old_diff: + result = counter + else: + result = counter - timedelta + + if time_only: + return result.time() + else: + return result + +def decimal_hours(timedelta, decimal_places=None): + """ + Return a decimal value of the number of hours that this timedelta + object refers to. + """ + hours = Decimal(timedelta.days*24) + Decimal(timedelta.seconds) / 3600 + if decimal_places: + return hours.quantize(Decimal(str(10**-decimal_places))) + return hours + +def week_containing(date): + if date.weekday(): + date -= datetime.timedelta(date.weekday()) + + return date, date + datetime.timedelta(6) + +try: + datetime.timedelta().total_seconds + def total_seconds(timedelta): + return timedelta.total_seconds() +except AttributeError: + def total_seconds(timedelta): + """ + Python < 2.7 does not have datetime.timedelta.total_seconds + """ + return timedelta.days * 86400 + timedelta.seconds + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/timedelta/models.py b/timedelta/models.py new file mode 100644 index 000000000..e69de29bb diff --git a/timedelta/templatetags/__init__.py b/timedelta/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/timedelta/templatetags/decimal_hours.py b/timedelta/templatetags/decimal_hours.py new file mode 100644 index 000000000..0a7a9196a --- /dev/null +++ b/timedelta/templatetags/decimal_hours.py @@ -0,0 +1,10 @@ +from django import template +register = template.Library() + +from ..helpers import decimal_hours as dh + +@register.filter(name='decimal_hours') +def decimal_hours(value, decimal_places=None): + if value is None: + return value + return dh(value, decimal_places) diff --git a/timedelta/templatetags/timedelta.py b/timedelta/templatetags/timedelta.py new file mode 100644 index 000000000..54684cfdd --- /dev/null +++ b/timedelta/templatetags/timedelta.py @@ -0,0 +1,34 @@ +from django import template +register = template.Library() + +# Don't really like using relative imports, but no choice here! +from ..helpers import parse, nice_repr, iso8601_repr, total_seconds as _total_seconds + + +@register.filter(name='timedelta') +def timedelta(value, display="long"): + if value is None: + return value + if isinstance(value, basestring): + value = parse(value) + return nice_repr(value, display) + + +@register.filter(name='iso8601') +def iso8601(value): + return timedelta(value, display='iso8601') + + +@register.filter(name='total_seconds') +def total_seconds(value): + if value is None: + return value + return _total_seconds(value) + + +@register.filter(name='total_seconds_sort') +def total_seconds(value, places=10): + if value is None: + return value + return ("%0" + str(places) + "i") % _total_seconds(value) + diff --git a/timedelta/tests.py b/timedelta/tests.py new file mode 100644 index 000000000..02c6af85d --- /dev/null +++ b/timedelta/tests.py @@ -0,0 +1,128 @@ +from unittest import TestCase +import datetime +import doctest + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import six + +from .fields import TimedeltaField +import timedelta.helpers +import timedelta.forms +import timedelta.widgets + +class MinMaxTestModel(models.Model): + min = TimedeltaField(min_value=datetime.timedelta(1)) + max = TimedeltaField(max_value=datetime.timedelta(1)) + minmax = TimedeltaField(min_value=datetime.timedelta(1), max_value=datetime.timedelta(7)) + +class IntTestModel(models.Model): + field = TimedeltaField(min_value=1, max_value=86400) + +class FloatTestModel(models.Model): + field = TimedeltaField(min_value=1.0, max_value=86400.0) + +class TimedeltaModelFieldTest(TestCase): + def test_validate(self): + test = MinMaxTestModel( + min=datetime.timedelta(1), + max=datetime.timedelta(1), + minmax=datetime.timedelta(1) + ) + test.full_clean() # This should have met validation requirements. + + test.min = datetime.timedelta(hours=23) + self.assertRaises(ValidationError, test.full_clean) + + test.min = datetime.timedelta(hours=25) + test.full_clean() + + test.max = datetime.timedelta(11) + self.assertRaises(ValidationError, test.full_clean) + + test.max = datetime.timedelta(hours=20) + test.full_clean() + + test.minmax = datetime.timedelta(0) + self.assertRaises(ValidationError, test.full_clean) + test.minmax = datetime.timedelta(22) + self.assertRaises(ValidationError, test.full_clean) + test.minmax = datetime.timedelta(6, hours=23, minutes=59, seconds=59) + test.full_clean() + + def test_from_int(self): + """ + Check that integers can be used to define the min_value and max_value + arguments, and that when assigned an integer, TimedeltaField converts + to timedelta. + """ + + test = IntTestModel() + + # valid + test.field = 3600 + self.assertEquals(test.field, datetime.timedelta(seconds=3600)) + test.full_clean() + + # invalid + test.field = 0 + self.assertRaises(ValidationError, test.full_clean) + + # also invalid + test.field = 86401 + self.assertRaises(ValidationError, test.full_clean) + + def test_from_float(self): + """ + Check that floats can be used to define the min_value and max_value + arguments, and that when assigned a float, TimedeltaField converts + to timedelta. + """ + + test = FloatTestModel() + + # valid + test.field = 3600.0 + self.assertEquals(test.field, datetime.timedelta(seconds=3600)) + test.full_clean() + + # invalid + test.field = 0.0 + self.assertRaises(ValidationError, test.full_clean) + + # also invalid + test.field = 86401.0 + self.assertRaises(ValidationError, test.full_clean) + + def test_deconstruct(self): + """ + Check that the deconstruct() method of TimedeltaField is returning the + min_value, max_value and default kwargs as floats. + """ + + field = TimedeltaField( + min_value=datetime.timedelta(minutes=5), + max_value=datetime.timedelta(minutes=15), + default=datetime.timedelta(minutes=30), + ) + + kwargs = field.deconstruct()[3] + self.assertEqual(kwargs['default'], 1800.0) + self.assertEqual(kwargs['max_value'], 900.0) + self.assertEqual(kwargs['min_value'], 300.0) + + def test_load_from_db(self): + obj = MinMaxTestModel.objects.create(min='2 days', max='2 minutes', minmax='3 days') + self.assertEquals(datetime.timedelta(2), obj.min) + self.assertEquals(datetime.timedelta(0, 120), obj.max) + self.assertEquals(datetime.timedelta(3), obj.minmax) + + obj = MinMaxTestModel.objects.get() + self.assertEquals(datetime.timedelta(2), obj.min) + self.assertEquals(datetime.timedelta(0, 120), obj.max) + self.assertEquals(datetime.timedelta(3), obj.minmax) + +def load_tests(loader, tests, ignore): + tests.addTests(doctest.DocTestSuite(timedelta.helpers)) + tests.addTests(doctest.DocTestSuite(timedelta.forms)) + return tests diff --git a/timedelta/widgets.py b/timedelta/widgets.py new file mode 100644 index 000000000..233c00f40 --- /dev/null +++ b/timedelta/widgets.py @@ -0,0 +1,46 @@ +import datetime + +from django import forms +from django.utils import six + +from .helpers import nice_repr, parse + +class TimedeltaWidget(forms.TextInput): + def __init__(self, *args, **kwargs): + return super(TimedeltaWidget, self).__init__(*args, **kwargs) + + def render(self, name, value, attrs=None): + if value is None: + value = "" + elif isinstance(value, six.string_types): + pass + else: + if isinstance(value, int): + value = datetime.timedelta(seconds=value) + value = nice_repr(value) + return super(TimedeltaWidget, self).render(name, value, attrs) + + def _has_changed(self, initial, data): + """ + We need to make sure the objects are of the canonical form, as a + string comparison may needlessly fail. + """ + if initial in ["", None] and data in ["", None]: + return False + + if initial in ["", None] or data in ["", None]: + return True + + if initial: + if not isinstance(initial, datetime.timedelta): + initial = parse(initial) + + if not isinstance(data, datetime.timedelta): + try: + data = parse(data) + except TypeError: + # initial didn't throw a TypeError, so this must be different + # from initial + return True + + return initial != data