Added a slightly tweaked version of timedelta, in order to support handling of TimedeltaFields.
- Legacy-Id: 9109
This commit is contained in:
parent
fed0e17ac1
commit
f94bcd93d7
1
timedelta/VERSION
Normal file
1
timedelta/VERSION
Normal file
|
@ -0,0 +1 @@
|
|||
0.7.3
|
19
timedelta/__init__.py
Normal file
19
timedelta/__init__.py
Normal 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
120
timedelta/fields.py
Normal 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
99
timedelta/forms.py
Normal 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
554
timedelta/helpers.py
Normal 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
0
timedelta/models.py
Normal file
0
timedelta/templatetags/__init__.py
Normal file
0
timedelta/templatetags/__init__.py
Normal file
10
timedelta/templatetags/decimal_hours.py
Normal file
10
timedelta/templatetags/decimal_hours.py
Normal 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)
|
34
timedelta/templatetags/timedelta.py
Normal file
34
timedelta/templatetags/timedelta.py
Normal 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
128
timedelta/tests.py
Normal 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
46
timedelta/widgets.py
Normal 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
|
Loading…
Reference in a new issue