Commits

Matthew Schinckel committed 7b6b737 Merge

Merge

  • Participants
  • Parent commits 3cdcbe9, 6af8d0d

Comments (0)

Files changed (13)

 b7a82602411da2d8bee570186d0ff7c26e4d2a4c 0.6.0
 b9c050166f8fba1937d61da33c6145e5de6216c4 0.6.1
 0ca08518d3eb60f9a5168b3279dcca1913f6b1f0 0.6.2
+89bc0ef4b22df33e7abca1ff2c76fe31dd1cf5f0 0.6.3
+15269c8c9001428fffecee2d4c67f32eac1b5541 0.6.4
+9b3cdbb15d0276f49ddb77e2a03f3f6f0546a940 0.6.5
+8c37b19265e4427ef00b4e9e8ffc0a0abcc7011c 0.6.6
+8626c421cc0b2526af1bea4ead20dc39be2f796c 0.6.7
+f3664b91e89fd92f90974485a7a93511f6cb481a 0.6.8
+c0e2cc5c107ac767c0de6856d422d1d027b20fd9 0.7.0
+Copyright (c) 2012, Matthew Schinckel.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright
+      notice, this list of conditions and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+    * The names of its contributors may be used to endorse or promote products
+      derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL MATTHEW SCHINCKEL BE LIABLE FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 recursive-include timedelta *
-include README
+include README
+include LICENSE
 
 Changelog
 ----------
+
+0.7.0: Support for django 1.5
+
+0.6.7: Added LICENSE file.
+
+0.6.6: Add in a couple of new template filters: total_seconds, and total_seconds_sort.
+       The latter zero-pads the value to 10 places, ideal for lexical sorting.
+       This correctly sorts timedeltas of up to around 10 years, if you need more
+       you can pass an argument to the filter.
+
+0.6.5: Empty string values in database now are returned as None for the field value.
+       Note that you must have field.null=True to store a NULL in the db.
+       I'm still not 100% happy with this: postgres may choke on empty string values when doing INTERVAL comparisons.
+       Thanks to Gustavo Dias jaimes and reidpr for the report/fix.
+
+0.6.4: Correctly parse '1w' (previously required 1wk).
+       Don't parse things like '1 hs', require '1 hrs'.
+       Test a bit harder.
+
+0.6.3: Correctly parse '1h' as one hour (previously required 1hr).
+
 0.6.2: Remember to include VERSION number.
 
 0.6.0: Added total_seconds helper (for python < 2.7)

File timedelta/VERSION

-0.6.2
+0.7.0

File timedelta/__init__.py

 __version__ = open(os.path.join(os.path.dirname(__file__), "VERSION")).read().strip()
 
 try:
-    from fields import TimedeltaField
-    from helpers import (
+    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:
+except (ImportError, ImproperlyConfigured):
     pass

File timedelta/fields.py

 from django.db import models
+from django.core.exceptions import ValidationError
 
 from collections import defaultdict
 import datetime
 
-from helpers import parse
-from forms import TimedeltaFormField
+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.
 
     
     description = "A datetime.timedelta object"
     
+    def __init__(self, *args, **kwargs):
+        self._min_value = kwargs.pop('min_value', None)
+        self._max_value = kwargs.pop('max_value', None)
+        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):
             return datetime.timedelta(seconds=value)
         if value == "":
-            return datetime.timedelta(0)
+            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, (str, unicode)):
             return value
         return str(value).replace(',', '')
         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)

File timedelta/forms.py

 import datetime
 from collections import defaultdict
 
-from widgets import TimedeltaWidget
-from helpers import parse
+from .widgets import TimedeltaWidget
+from .helpers import parse
 
 class TimedeltaFormField(forms.Field):
     default_error_messages = {
         super(TimedeltaFormField, self).clean(value)
         if value == '' and not self.required:
             return u''
-        
-        data = defaultdict(float)
         try:
             return parse(value)
         except TypeError:
             raise forms.ValidationError(self.error_messages['invalid'])
-            
-        return datetime.timedelta(**data)
 
 class TimedeltaChoicesField(TimedeltaFormField):
     def __init__(self, *args, **kwargs):

File timedelta/helpers.py

             if value:
                 result.append('%d%c' % (value, format))
 
-    return result.join("")
+    return "".join(result)
 
 def parse(string):
     """
     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
+    
     """
-    # This is the format we get from sometimes Postgres, and from serialization
-    d = re.match(r'((?P<days>\d+) days?,? )?(?P<hours>\d+):'
-                 r'(?P<minutes>\d+)(:(?P<seconds>\d+))?',
+    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<hours>\d+):'
+                 r'(?P<minutes>\d+)(:(?P<seconds>\d+(\.\d+)?))?$',
                  unicode(string))
     if d: 
         d = d.groupdict(0)
     else:
         # This is the more flexible format
         d = re.match(
-                     r'^((?P<weeks>((\d*\.\d+)|\d+))\W*w((ee)?k(s)?)(,)?\W*)?'
+                     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<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*$',
                      unicode(string))
         if not d:
             raise TypeError("'%s' is not a valid time interval" % string)
-        d = d.groupdict()
+        d = d.groupdict(0)
     
-    return datetime.timedelta(**dict(( (k, float(v)) for k,v in d.items() 
-        if v is not None )))
+    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)
     """
     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"
 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)
     """
     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."
 def multiply(obj, val):
     """
     Allows for the multiplication of timedeltas by float values.
+    >>> multiply(datetime.timedelta(seconds=20), 1.5)
+    datetime.timedelta(0, 30)
     """
     
     assert isinstance(obj, datetime.timedelta), "First argument must be a 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)
     """
     
     assert isinstance(obj, (datetime.datetime, datetime.timedelta, datetime.time)), "First argument must be datetime, time or timedelta."
         """
         return timedelta.days * 86400 + timedelta.seconds
 
+if __name__ == "__main__":
+    import doctest
+    doctest.testmod()

File timedelta/templatetags/decimal_hours.py

 
 @register.filter(name='decimal_hours')
 def decimal_hours(value, decimal_places=None):
-    return dh(value, decimal_places)
+    if value is None:
+        return value
+    return dh(value, decimal_places)

File timedelta/templatetags/timedelta.py

 register = template.Library()
 
 # Don't really like using relative imports, but no choice here!
-from ..helpers import nice_repr, iso8601_repr
+from ..helpers import nice_repr, iso8601_repr, total_seconds as _total_seconds
 
 @register.filter(name='timedelta')
 def timedelta(value, display="long"):
+    if value is None:
+        return value
     return nice_repr(value, display)
 
 @register.filter(name='iso8601')
 def iso8601(value):
+    if value is None:
+        return value
     return iso8601_repr(value)
+
+@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)
+

File timedelta/tests.py

+from django.db import models
+from django.core.exceptions import ValidationError
 from unittest import TestCase
+
 import datetime
-from forms import TimedeltaFormField
-from widgets import TimedeltaWidget
-from helpers import *
+
+from .forms import TimedeltaFormField
+from .fields import TimedeltaField
+from .widgets import TimedeltaWidget
+from .helpers import *
 
 class TimedeltaWidgetTest(TestCase):
     def test_render(self):
         u'<input type="text" name="" value="3 days, 12 hours" />'
         """
 
+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 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()
+
 class TimedeltaFormFieldTest(TestCase):
     def test_clean(self):
         """
         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)
+        >>> t.clean('3 days, 8:42:42.3.42161')
+        Traceback (most recent call last):
+        ValidationError: [u'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')
         Traceback (most recent call last):
         ValidationError: [u'Enter a valid time span: e.g. "3 days, 4 hours, 2 minutes"']
         """
+    
 
 class TimedeltaHelpersTest(TestCase):
     def test_parse(self):

File timedelta/widgets.py

 from django import forms
 import datetime
 
-from helpers import nice_repr, parse
+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):
+    def render(self, name, value, attrs=None):
         if value is None:
             value = ""
         elif isinstance(value, (str, unicode)):