Added a slightly tweaked version of timedelta, in order to support handling of TimedeltaFields.

- Legacy-Id: 9109
This commit is contained in:
Henrik Levkowetz 2015-02-23 12:27:18 +00:00
parent fed0e17ac1
commit f94bcd93d7
11 changed files with 1011 additions and 0 deletions

1
timedelta/VERSION Normal file
View file

@ -0,0 +1 @@
0.7.3

19
timedelta/__init__.py Normal file
View file

@ -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

120
timedelta/fields.py Normal file
View file

@ -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

99
timedelta/forms.py Normal file
View file

@ -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)

554
timedelta/helpers.py Normal file
View file

@ -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<days>[-+]?\d+) days?,? )?(?P<sign>[-+]?)(?P<hours>\d+):'
r'(?P<minutes>\d+)(:(?P<seconds>\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<weeks>-?((\d*\.\d+)|\d+))\W*w((ee)?(k(s)?)?)(,)?\W*)?'
r'((?P<days>-?((\d*\.\d+)|\d+))\W*d(ay(s)?)?(,)?\W*)?'
r'((?P<hours>-?((\d*\.\d+)|\d+))\W*h(ou)?(r(s)?)?(,)?\W*)?'
r'((?P<minutes>-?((\d*\.\d+)|\d+))\W*m(in(ute)?(s)?)?(,)?\W*)?'
r'((?P<seconds>-?((\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()

0
timedelta/models.py Normal file
View file

View file

View file

@ -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)

View file

@ -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)

128
timedelta/tests.py Normal file
View file

@ -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

46
timedelta/widgets.py Normal file
View file

@ -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