Commits

Tim Savage committed 15c5c5c

Moved types into core, refactored models section to be more consistent with the rest of the library as well as django. Added unit tests for color validator, fixed bugs discovered by unittests.

Comments (0)

Files changed (15)

.idea/django-extras.iml

 <module type="PYTHON_MODULE" version="4">
   <component name="NewModuleRootManager">
     <content url="file://$MODULE_DIR$" />
-    <orderEntry type="jdk" jdkName="Python 2.7.2 virtualenv at ~\.virtualenvs\dev-django1.4" jdkType="Python SDK" />
+    <orderEntry type="jdk" jdkName="Python 2.7.2 virtualenv at ~\.virtualenvs\django_extras" jdkType="Python SDK" />
     <orderEntry type="sourceFolder" forTests="false" />
   </component>
   <component name="TemplatesService">
   <component name="ProjectResources">
     <default-html-doctype>http://www.w3.org/1999/xhtml</default-html-doctype>
   </component>
-  <component name="ProjectRootManager" version="2" project-jdk-name="Python 2.7.2 virtualenv at ~\.virtualenvs\dev-django1.4" project-jdk-type="Python SDK" />
+  <component name="ProjectRootManager" version="2" project-jdk-name="Python 2.7.2 virtualenv at ~\.virtualenvs\django_extras" project-jdk-type="Python SDK" />
 </project>
 

django_extras/core/types.py

+import decimal
+
+
+NO_CURRENCY_CODE = 'XXX'
+NO_CURRENCY_NUMBER = 999
+
+class Currency(object):
+    """
+    Represents a currency.
+    """
+    __slots__ = ('code', 'number', 'name', 'symbol', )
+
+    def __init__(self, code, number, name, symbol=''):
+        self.code = code
+        self.number = number
+        self.name = name
+        self.symbol = symbol
+
+    # Comparison operators
+    def __eq__(self, other):
+        if other is None:
+            return False
+        if isinstance(other, Currency):
+            return self.code == other.code
+        return False
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    @property
+    def is_no_currency(self):
+        return self.code == NO_CURRENCY_CODE
+
+NoCurrency = Currency(NO_CURRENCY_CODE, NO_CURRENCY_NUMBER, 'No Currency')
+
+
+def decimal_value(value):
+    """
+    Convert a value into a decimal and handle any conversion required.
+    
+    @raises ValueError if trying to convert a value that does not translate to decimal.
+    """
+    if value is None:
+        raise ValueError('None is not a valid money value.')
+    if not isinstance(value, decimal.Decimal):
+        try:
+            return decimal.Decimal(str(value))
+        except decimal.InvalidOperation:
+            raise ValueError('Value could not be converted into a decimal.')
+    return value   
+
+
+class Money(object):
+    """
+    Represents a monetary quantity.
+    """
+    __slots__ = ('_amount', 'currency', )
+
+    def __init__(self, amount=decimal.Decimal('0.0'), currency=NoCurrency):
+        self._amount = decimal_value(amount)
+        self.currency = currency
+
+    def __repr__(self):
+        return '%5.4f' % self._amount
+
+    def __unicode__(self):
+        return self.format()
+
+    def __hash__(self):
+        return self.__repr__()
+
+    # Math operators
+    def __pos__(self):
+        return Money(amount=self._amount)
+
+    def __neg__(self):
+        return Money(amount=-self._amount)
+
+    def __add__(self, other):
+        if self._can_compare(other):
+            return Money(amount=self._amount + other._amount)
+        else:
+            return Money(amount=self._amount + decimal_value(other))
+
+    def __sub__(self, other):
+        if self._can_compare(other):
+            return Money(amount=self._amount - other._amount)
+        else:
+            return Money(amount=self._amount - decimal_value(other))
+
+    def __mul__(self, other):
+        if self._can_compare(other):
+            raise TypeError, 'Can not multiply by a monetary quantity.'
+        else:
+            return Money(amount = self._amount * decimal_value(other))
+
+    def __div__(self, other):
+        if self._can_compare(other):
+            raise TypeError, 'Can not divide by a monetary quantity.'
+        else:
+            return Money(amount = self._amount * decimal_value(other))
+
+    def __rmod__(self, other):
+        """
+        Re-purposed to calculate a percentage of a monetary quantity.
+
+        >>> 10 % money.Money(500)
+        50.0000
+        """
+        if self._can_compare(other):
+            raise TypeError, 'Can not use a monetary quantity as a percentage.'
+        else:
+            #noinspection PyTypeChecker
+            percentage = decimal_value(other) * self._amount / 100
+            return Money(amount=percentage)
+
+    def __abs__(self):
+        return Money(abs(self._amount))
+
+    __radd__ = __add__
+    __rsub__ = __sub__
+    __rmul__ = __mul__
+    __rdiv__ = __div__
+
+    # Comparison operators
+    def __eq__(self, other):
+        if other is None:
+            return False
+        if isinstance(other, Money):
+            return self.currency == other.currency and self._amount == other._amount
+        return self._amount == decimal.Decimal(str(other))
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __lt__(self, other):
+        if self._can_compare(other):
+            return self._amount < other._amount
+        else:
+            return self._amount < decimal.Decimal(str(other))
+
+    def __le__(self, other):
+        return self < other or self == other
+
+    def __gt__(self, other):
+        if self._can_compare(other):
+            return self._amount > other._amount
+        else:
+            return self._amount > decimal.Decimal(str(other))
+
+    def __ge__(self, other):
+        return self > other or self == other
+
+    # Type conversion
+    def __int__(self):
+        return self._amount.__int__()
+
+    def __float__(self):
+        return self._amount.__float__()
+
+    # Pickle support
+    def __getstate__(self):
+        return {
+            'amount': self._amount,
+            'currency': self.currency.code,
+        }
+
+    def __setstate__(self, state):
+        self._amount = decimal_value(state.get('amount', '0.0'))
+
+    def _can_compare(self, right):
+        """
+        Check if we are able to compare right value.
+        
+        @raises Value Error if currencies do not match.
+        """
+        if isinstance(right, Money):
+            if self.currency == right.currency:
+                return True
+            else:
+                raise ValueError('Currencies do not match')
+        else:
+            return False
+
+    def format(self, places=2, currency_symbol='', separator=',', decimal_place='.', positive_sign='',
+               negative_sign='-', trailing_negative=''):
+        """
+        Format money value to a string
+        """
+        # Round and split number
+        q = decimal.Decimal(10) ** -places
+        sign, digits, exp = self._amount.quantize(q).as_tuple()
+
+        # Convert digits to string
+        results = []
+        digits = map(str, digits)
+        build, next = results.append, digits.pop
+
+        # Append trailing negative sign
+        if sign:
+            build(trailing_negative)
+
+        # Cut off decimal digits
+        if places:
+            for i in range(places):
+                build(next())
+            build(decimal_place)
+
+        # Append digits
+        if not digits:
+            build('0')
+        else:
+            idx = 0
+            while digits:
+                build(next())
+                idx += 1
+                if not idx % 3 and digits:
+                    build(str(separator))
+
+        # Append currency symbol and sign before returning result
+        build(str(currency_symbol))
+        build(str(negative_sign if sign else positive_sign))
+        return ''.join(reversed(results))

django_extras/core/validators.py

 
 color_re = re.compile(
     r'(^#[a-f0-9]{3,6}$)' # Hash style
-    r'|(^rgb\s*\(\s*((2[0-4][0-9]|25[0-5]|1?[0-9]{2}|100%|[0-9]{1,2}%)\s*,\s*){2}((2[0-4][0-9]|25[0-5]|1?[0-9]{2}|100%|[0-9]{1,2}%)\s*)\))' # rgb style
-    r'|(^rgba\s*\(\s*((2[0-4][0-9]|25[0-5]|1?[0-9]{2}|100%|[0-9]{1,2}%)\s*,\s*){3}(((0(\.[0-9]+)?)|1)\s*)\)$)' # rgba style
-    r'|(^hsl\s*\(\s*(360|3[0-5][0-9]|[0-2]?[0-9]{2})\s*,\s*(100%|[0-9]{1,2}%)\s*,\s*(100%|[0-9]{1,2}%)\s*\)$)' # hsl style
-    r'|(^hsla\s*\(\s*(360|3[0-5][0-9]|[0-2]?[0-9]{2})\s*,\s*((100%|[0-9]{1,2}%)\s*,\s*){2}(((0(\.[0-9]+)?)|1)\s*)\)$)', re.IGNORECASE) # hsla style
+    r'|(^rgb\s*\(\s*((2[0-4][0-9]|25[0-5]|1?[0-9]{1,2}|100%|[0-9]{1,2}%)\s*,\s*){2}((2[0-4][0-9]|25[0-5]|1?[0-9]{1,2}|100%|[0-9]{1,2}%)\s*)\))' # rgb style
+    r'|(^rgba\s*\(\s*((2[0-4][0-9]|25[0-5]|1?[0-9]{1,2}|100%|[0-9]{1,2}%)\s*,\s*){3}(((0?(\.[0-9]+)?)|1)\s*)\)$)' # rgba style
+    r'|(^hsl\s*\(\s*(360|3[0-5][0-9]|[0-2]?[0-9]{1,2})\s*,\s*(100%|[0-9]{1,2}%)\s*,\s*(100%|[0-9]{1,2}%)\s*\)$)' # hsl style
+    r'|(^hsla\s*\(\s*(360|3[0-5][0-9]|[0-2]?[0-9]{1,2})\s*,\s*((100%|[0-9]{1,2}%)\s*,\s*){2}(((0?(\.[0-9]+)?)|1)\s*)\)$)', re.IGNORECASE) # hsla style
 validate_color = RegexValidator(color_re, _(u'Enter a valid color in CSS format.'), 'invalid')

django_extras/db/fields/__init__.py

-from django.core import exceptions
-from django.db import models
-from django.utils.translation import ugettext_lazy as _
-from django_extras.core import validators
-from django_extras.types import Money
-from django_extras import forms
-
-
-class ColorField(models.CharField):
-    """
-    Database field that represents a color value.
-    """
-    default_error_messages = {
-        'invalid': _(u'This value must be a CSS color value.'),
-    }
-    description = _("Color value")
-
-    def __init__(self, verbose_name=None, name=None, max_length=30, **kwargs):
-        kwargs.setdefault('verbose_name', verbose_name)
-        kwargs.setdefault('name', name)
-        kwargs.setdefault('max_length', max_length)
-        super(ColorField, self).__init__(**kwargs)
-        self.validators.append(validators.validate_color)
-
-    def formfield(self, **kwargs):
-        defaults = {
-            'widget': forms.ColorPickerWidget
-        }
-        defaults.update(kwargs)
-        return super(ColorField, self).formfield(**defaults)
-
-
-class MoneyField(models.DecimalField):
-    """
-    Database field that represents a Money amount.
-    """
-    default_error_messages = {
-        'invalid': _(u'This value must be a monetary amount.'),
-    }
-    description = _("Monetary amount")
-
-    def __init__(self, verbose_name=None, name=None, max_digits=20, decimal_places=4, **kwargs):
-        super(MoneyField, self).__init__(verbose_name, name, max_digits, decimal_places, **kwargs)
-
-    def to_python(self, value):
-        if value is None:
-            return value
-        try:
-            return Money(value)
-        except ValueError:
-            raise exceptions.ValidationError(self.error_messages['invalid'])
-
-    def get_db_prep_save(self, value, connection):
-        value = self.to_python(value)
-        if value is not None:
-            value = value._amount
-        super(MoneyField, self).get_db_prep_save(value, connection)
-
-    def formfield(self, **kwargs):
-        defaults = {
-#            'form_class': forms.DecimalField,
-        }
-        defaults.update(kwargs)
-        return super(MoneyField, self).formfield(**defaults)

django_extras/db/models.py

-from collections import OrderedDict
-from django.db.models.fields import NOT_PROVIDED
-
-
-class ChoiceEnum(object):
-    """
-    Defines a choice set for use with Django Models.
-
-    ChoiceEnum can be used in one of two ways, due to a side effect of how dictionaries are sorted in Python
-    (dictionaries and by extension **kwargs are implemented as a hash map) the order is not preserved.
-
-    The nicest way to use ChoiceEnum (without guaranteed ordering):
-
-        MY_CHOICES = ChoiceEnum(
-            OPTION_ONE = ('value', 'Verbose value'),
-            OPTION_TWO = ('value2', 'Verbose value 2', True), # Default, the value can be anything ;)
-        )
-
-    The less nice way to use ChoiceEnum (with guaranteed ordering):
-
-        MY_CHOICES = ChoiceEnum(
-            ('OPTION_ONE', ('value', 'Verbose value')),
-            ('OPTION_TWO', ('value2', 'Verbose value 2', True)), # Default, the value can be anything ;)
-        )
-
-    They can then be used for field choices:
-
-        foo = models.CharField(max_length=MY_CHOICES.max_length, choices=MY_CHOICES, default=MY_CHOICES.default)
-
-    or with the kwargs helper:
-
-        foo = models.CharField(**MY_CHOICES.kwargs)
-
-    And values referenced using:
-
-        MY_CHOICES.OPTION_ONE
-
-    Display values can be accessed via:
-
-        MY_CHOICES.OPTION_ONE__display
-
-    Convert a value to a display string:
-
-        MY_CHOICES % 'value'
-
-    """
-    __slots__ = ('__default', '__value_map', '__choices', '__max_length')
-
-    def __init__(self, *args, **entries):
-        if args:
-            entries = OrderedDict(args)
-        if not entries:
-            raise ValueError('No entries have been provided.')
-
-        self.__default = NOT_PROVIDED
-        self.__max_length = 0
-        self.__value_map = dict()
-        self.__choices = OrderedDict()
-
-        map(self.__parse_entry, entries.iteritems())
-
-    def __parse_entry(self, entry):
-        key, value = entry
-
-        if not isinstance(value, (tuple, list)):
-            raise TypeError('Choice options should be a tuple or list.')
-        value_len = len(value)
-        if not value_len in (2, 3):
-            raise ValueError('Expected choice entry in the form (Value, Verbose Value, [default]).')
-
-        if value_len == 3:
-            self.__default = value[0]
-        if isinstance(value[0], basestring):
-            self.__max_length = max(self.__max_length, len(value[0]))
-
-        self.__value_map[value[0]] = value[1]
-        self.__choices[key] = value[0], value[1]
-
-    def __iter__(self):
-        """
-        Return choice list for use in model definition.
-        """
-        return iter(self.__choices.values())
-
-    def __contains__(self, value):
-        """
-        Check if a choice value is in this enum.
-
-        @param value Choice value.
-        """
-        return value in self.__value_map
-
-    def __getattr__(self, item):
-        """
-        Get the value of an Enum.
-        """
-        if item.endswith('__display'):
-            return self.__choices[item[:-9]][1]
-        else:
-            return self.__choices[item][0]
-
-    def __mod__(self, other):
-        """
-        Resolve a value to it's display version.
-        """
-        return self.__value_map[other]
-
-    @property
-    def max_length(self):
-        """
-        Length of maximum value
-        """
-        return self.__max_length
-
-    @property
-    def default(self):
-        """
-        The default value.
-        """
-        return self.__default
-
-    @property
-    def kwargs(self):
-        """
-        Helper to simplify assignment of choices to a model field.
-        """
-        kwargs = {
-            'choices': self,
-            'default': self.__default,
-        }
-        if self.__max_length:
-            kwargs['max_length'] = self.__max_length
-        return kwargs
-
-    def resolve_value(self, value):
-        """
-        Resolve a value to it's display version.
-        """
-        return self._value_map[value]

django_extras/db/models/__init__.py

+from django.db.models import *
+from django_extras.db.models.choices import *
+from django_extras.db.models.fields import *

django_extras/db/models/choices.py

+from collections import OrderedDict
+
+
+class ChoiceEnum(object):
+    """
+    Defines a choice set for use with Django Models.
+
+    ChoiceEnum can be used in one of two ways, due to a side effect of how dictionaries are sorted in Python
+    (dictionaries and by extension **kwargs are implemented as a hash map) the order is not preserved.
+
+    The nicest way to use ChoiceEnum (without guaranteed ordering):
+
+        MY_CHOICES = ChoiceEnum(
+            OPTION_ONE = ('value', 'Verbose value'),
+            OPTION_TWO = ('value2', 'Verbose value 2', True), # Default, the value can be anything ;)
+        )
+
+    The less nice way to use ChoiceEnum (with guaranteed ordering):
+
+        MY_CHOICES = ChoiceEnum(
+            ('OPTION_ONE', ('value', 'Verbose value')),
+            ('OPTION_TWO', ('value2', 'Verbose value 2', True)), # Default, the value can be anything ;)
+        )
+
+    They can then be used for field choices:
+
+        foo = models.CharField(max_length=MY_CHOICES.max_length, choices=MY_CHOICES, default=MY_CHOICES.default)
+
+    or with the kwargs helper:
+
+        foo = models.CharField(**MY_CHOICES.kwargs)
+
+    And values referenced using:
+
+        MY_CHOICES.OPTION_ONE
+
+    Display values can be accessed via:
+
+        MY_CHOICES.OPTION_ONE__display
+
+    Convert a value to a display string:
+
+        MY_CHOICES % 'value'
+
+    """
+    __slots__ = ('__default', '__value_map', '__choices', '__max_length')
+
+    def __init__(self, *args, **entries):
+        if args:
+            entries = OrderedDict(args)
+        if not entries:
+            raise ValueError('No entries have been provided.')
+
+        self.__default = NOT_PROVIDED
+        self.__max_length = 0
+        self.__value_map = dict()
+        self.__choices = OrderedDict()
+
+        map(self.__parse_entry, entries.iteritems())
+
+    def __parse_entry(self, entry):
+        key, value = entry
+
+        if not isinstance(value, (tuple, list)):
+            raise TypeError('Choice options should be a tuple or list.')
+        value_len = len(value)
+        if not value_len in (2, 3):
+            raise ValueError('Expected choice entry in the form (Value, Verbose Value, [default]).')
+
+        if value_len == 3:
+            self.__default = value[0]
+        if isinstance(value[0], basestring):
+            self.__max_length = max(self.__max_length, len(value[0]))
+
+        self.__value_map[value[0]] = value[1]
+        self.__choices[key] = value[0], value[1]
+
+    def __iter__(self):
+        """
+        Return choice list for use in model definition.
+        """
+        return iter(self.__choices.values())
+
+    def __contains__(self, value):
+        """
+        Check if a choice value is in this enum.
+
+        @param value Choice value.
+        """
+        return value in self.__value_map
+
+    def __getattr__(self, item):
+        """
+        Get the value of an Enum.
+        """
+        if item.endswith('__display'):
+            return self.__choices[item[:-9]][1]
+        else:
+            return self.__choices[item][0]
+
+    def __mod__(self, other):
+        """
+        Resolve a value to it's display version.
+        """
+        return self.__value_map[other]
+
+    @property
+    def max_length(self):
+        """
+        Length of maximum value
+        """
+        return self.__max_length
+
+    @property
+    def default(self):
+        """
+        The default value.
+        """
+        return self.__default
+
+    @property
+    def kwargs(self):
+        """
+        Helper to simplify assignment of choices to a model field.
+        """
+        kwargs = {
+            'choices': self,
+            'default': self.__default,
+        }
+        if self.__max_length:
+            kwargs['max_length'] = self.__max_length
+        return kwargs
+
+    def resolve_value(self, value):
+        """
+        Resolve a value to it's display version.
+        """
+        return self._value_map[value]

django_extras/db/models/fields.py

+from django.core import exceptions
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django_extras import forms
+from django_extras.core import validators
+from django_extras.core.types import Money
+
+
+class ColorField(models.CharField):
+    """
+    Database field that represents a color value.
+    """
+    default_error_messages = {
+        'invalid': _(u'This value must be a CSS color value.'),
+    }
+    description = _("Color value")
+
+    def __init__(self, verbose_name=None, name=None, max_length=30, **kwargs):
+        kwargs.setdefault('verbose_name', verbose_name)
+        kwargs.setdefault('name', name)
+        kwargs.setdefault('max_length', max_length)
+        super(ColorField, self).__init__(**kwargs)
+        self.validators.append(validators.validate_color)
+
+    def formfield(self, **kwargs):
+        defaults = {
+            'widget': forms.ColorPickerWidget
+        }
+        defaults.update(kwargs)
+        return super(ColorField, self).formfield(**defaults)
+
+
+class MoneyField(models.DecimalField):
+    """
+    Database field that represents a Money amount.
+    """
+    default_error_messages = {
+        'invalid': _(u'This value must be a monetary amount.'),
+    }
+    description = _("Monetary amount")
+
+    def __init__(self, verbose_name=None, name=None, max_digits=20, decimal_places=4, **kwargs):
+        super(MoneyField, self).__init__(verbose_name, name, max_digits, decimal_places, **kwargs)
+
+    def to_python(self, value):
+        if value is None:
+            return value
+        try:
+            return Money(value)
+        except ValueError:
+            raise exceptions.ValidationError(self.error_messages['invalid'])
+
+    def get_db_prep_save(self, value, connection):
+        value = self.to_python(value)
+        if value is not None:
+            value = value._amount
+        super(MoneyField, self).get_db_prep_save(value, connection)
+
+    def formfield(self, **kwargs):
+        defaults = {
+#            'form_class': forms.DecimalField,
+        }
+        defaults.update(kwargs)
+        return super(MoneyField, self).formfield(**defaults)

django_extras/tests/__init__.py

-from contrib.auth import *
-from types import *
+from django_extras.tests.contrib.auth import *
+from django_extras.tests.core.types import *
+from django_extras.tests.core.validators import *

django_extras/tests/core/__init__.py

+__author__ = 'tsavage'

django_extras/tests/core/types.py

+#coding=UTF-8
+from django import test
+from django_extras.core.types import Money
+
+class MoneyTestCase(test.TestCase):
+
+    LARGE = Money('200000000.0000')
+    SMALL = Money('100.000')
+    NEGATIVE = Money('-200000.0000')
+
+    FORMAT_POSITIVE = Money('123456.789')
+    FORMAT_NEGATIVE = Money('-12345.6789')
+
+    def test_format_default(self):
+        self.assertEqual('123,456.79', self.FORMAT_POSITIVE.format())
+        self.assertEqual('-12,345.68', self.FORMAT_NEGATIVE.format())
+
+    def test_format_places(self):
+        self.assertEqual('123,456.7890', self.FORMAT_POSITIVE.format(places=4))
+        self.assertEqual('-12,345.678900', self.FORMAT_NEGATIVE.format(places=6))
+
+    def test_format_currency_symbol(self):
+        self.assertEqual('$123,456.79', self.FORMAT_POSITIVE.format(currency_symbol='$'))
+        self.assertEqual('-£12,345.68', self.FORMAT_NEGATIVE.format(currency_symbol='£'))
+
+    def test_format_europe(self):
+        self.assertEqual('123.456,79', self.FORMAT_POSITIVE.format(separator='.', decimal_place=','))
+        self.assertEqual('-12.345,68', self.FORMAT_NEGATIVE.format(separator='.', decimal_place=','))
+
+    def test_format_signs(self):
+        self.assertEqual('p123,456.79', self.FORMAT_POSITIVE.format(positive_sign="p"))
+        self.assertEqual('n12,345.68', self.FORMAT_NEGATIVE.format(negative_sign="n"))
+
+    def test_format_trailing_negative(self):
+        self.assertEqual('123,456.79', self.FORMAT_POSITIVE.format(trailing_negative=" neg"))
+        self.assertEqual('-12,345.68 neg', self.FORMAT_NEGATIVE.format(trailing_negative=" neg"))
+

django_extras/tests/core/validators.py

+from django import test
+from django_extras.core import validators
+
+
+class ColorValidator(test.TestCase):
+    def test_hash(self):
+        validators.validate_color('#123')
+        validators.validate_color('#abc')
+        validators.validate_color('#45d')
+        validators.validate_color('#6789ef')
+        validators.validate_color('#abcdef')
+        validators.validate_color('#0a1b2c')
+
+    def test_rgb(self):
+        validators.validate_color('rgb(0,1,2)')
+        validators.validate_color('rgb(34,56,78)')
+        validators.validate_color('rgb(191,234,156)')
+        validators.validate_color('rgb(255,255,255)')
+
+    def test_rgba(self):
+        validators.validate_color('rgba(0, 1, 2, 0)')
+        validators.validate_color('rgba(34, 56, 78, 0.1)')
+        validators.validate_color('rgba(191, 234, 156, .2)')
+        validators.validate_color('rgba(255, 255, 255, 1)')
+
+    def test_hsl(self):
+        validators.validate_color('hsl(1,2%,3%)')
+        validators.validate_color('hsl(23,45%,67%)')
+        validators.validate_color('hsl(345,89%,90%)')
+        validators.validate_color('hsl(360,100%,100%)')
+
+    def test_hsla(self):
+        validators.validate_color('hsla(1,2%,3%,0)')
+        validators.validate_color('hsla(23,45%,67%,0.1)')
+        validators.validate_color('hsla(345,89%,90%,.2)')
+        validators.validate_color('hsla(360,100%,100%,1)')

django_extras/tests/types.py

-#coding=UTF-8
-from unittest import TestCase
-from django_extras.types import Money
-
-class MoneyTestCase(TestCase):
-
-    LARGE = Money('200000000.0000')
-    SMALL = Money('100.000')
-    NEGATIVE = Money('-200000.0000')
-
-    FORMAT_POSITIVE = Money('123456.789')
-    FORMAT_NEGATIVE = Money('-12345.6789')
-
-    def test_format_default(self):
-        self.assertEqual('123,456.79', self.FORMAT_POSITIVE.format())
-        self.assertEqual('-12,345.68', self.FORMAT_NEGATIVE.format())
-
-    def test_format_places(self):
-        self.assertEqual('123,456.7890', self.FORMAT_POSITIVE.format(places=4))
-        self.assertEqual('-12,345.678900', self.FORMAT_NEGATIVE.format(places=6))
-
-    def test_format_currency_symbol(self):
-        self.assertEqual('$123,456.79', self.FORMAT_POSITIVE.format(currency_symbol='$'))
-        self.assertEqual('-£12,345.68', self.FORMAT_NEGATIVE.format(currency_symbol='£'))
-
-    def test_format_europe(self):
-        self.assertEqual('123.456,79', self.FORMAT_POSITIVE.format(separator='.', decimal_place=','))
-        self.assertEqual('-12.345,68', self.FORMAT_NEGATIVE.format(separator='.', decimal_place=','))
-
-    def test_format_signs(self):
-        self.assertEqual('p123,456.79', self.FORMAT_POSITIVE.format(positive_sign="p"))
-        self.assertEqual('n12,345.68', self.FORMAT_NEGATIVE.format(negative_sign="n"))
-
-    def test_format_trailing_negative(self):
-        self.assertEqual('123,456.79', self.FORMAT_POSITIVE.format(trailing_negative=" neg"))
-        self.assertEqual('-12,345.68 neg', self.FORMAT_NEGATIVE.format(trailing_negative=" neg"))
-

django_extras/types.py

-import decimal
-
-
-NO_CURRENCY_CODE = 'XXX'
-NO_CURRENCY_NUMBER = 999
-
-class Currency(object):
-    """
-    Represents a currency.
-    """
-    __slots__ = ('code', 'number', 'name', 'symbol', )
-
-    def __init__(self, code, number, name, symbol=''):
-        self.code = code
-        self.number = number
-        self.name = name
-        self.symbol = symbol
-
-    # Comparison operators
-    def __eq__(self, other):
-        if other is None:
-            return False
-        if isinstance(other, Currency):
-            return self.code == other.code
-        return False
-
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-    @property
-    def is_no_currency(self):
-        return self.code == NO_CURRENCY_CODE
-
-NoCurrency = Currency(NO_CURRENCY_CODE, NO_CURRENCY_NUMBER, 'No Currency')
-
-
-def decimal_value(value):
-    """
-    Convert a value into a decimal and handle any conversion required.
-    
-    @raises ValueError if trying to convert a value that does not translate to decimal.
-    """
-    if value is None:
-        raise ValueError('None is not a valid money value.')
-    if not isinstance(value, decimal.Decimal):
-        try:
-            return decimal.Decimal(str(value))
-        except decimal.InvalidOperation:
-            raise ValueError('Value could not be converted into a decimal.')
-    return value   
-
-
-class Money(object):
-    """
-    Represents a monetary quantity.
-    """
-    __slots__ = ('_amount', 'currency', )
-
-    def __init__(self, amount=decimal.Decimal('0.0'), currency=NoCurrency):
-        self._amount = decimal_value(amount)
-        self.currency = currency
-
-    def __repr__(self):
-        return '%5.4f' % self._amount
-
-    def __unicode__(self):
-        return self.format()
-
-    def __hash__(self):
-        return self.__repr__()
-
-    # Math operators
-    def __pos__(self):
-        return Money(amount=self._amount)
-
-    def __neg__(self):
-        return Money(amount=-self._amount)
-
-    def __add__(self, other):
-        if self._can_compare(other):
-            return Money(amount=self._amount + other._amount)
-        else:
-            return Money(amount=self._amount + decimal_value(other))
-
-    def __sub__(self, other):
-        if self._can_compare(other):
-            return Money(amount=self._amount - other._amount)
-        else:
-            return Money(amount=self._amount - decimal_value(other))
-
-    def __mul__(self, other):
-        if self._can_compare(other):
-            raise TypeError, 'Can not multiply by a monetary quantity.'
-        else:
-            return Money(amount = self._amount * decimal_value(other))
-
-    def __div__(self, other):
-        if self._can_compare(other):
-            raise TypeError, 'Can not divide by a monetary quantity.'
-        else:
-            return Money(amount = self._amount * decimal_value(other))
-
-    def __rmod__(self, other):
-        """
-        Re-purposed to calculate a percentage of a monetary quantity.
-
-        >>> 10 % money.Money(500)
-        50.0000
-        """
-        if self._can_compare(other):
-            raise TypeError, 'Can not use a monetary quantity as a percentage.'
-        else:
-            #noinspection PyTypeChecker
-            percentage = decimal_value(other) * self._amount / 100
-            return Money(amount=percentage)
-
-    def __abs__(self):
-        return Money(abs(self._amount))
-
-    __radd__ = __add__
-    __rsub__ = __sub__
-    __rmul__ = __mul__
-    __rdiv__ = __div__
-
-    # Comparison operators
-    def __eq__(self, other):
-        if other is None:
-            return False
-        if isinstance(other, Money):
-            return self.currency == other.currency and self._amount == other._amount
-        return self._amount == decimal.Decimal(str(other))
-
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-    def __lt__(self, other):
-        if self._can_compare(other):
-            return self._amount < other._amount
-        else:
-            return self._amount < decimal.Decimal(str(other))
-
-    def __le__(self, other):
-        return self < other or self == other
-
-    def __gt__(self, other):
-        if self._can_compare(other):
-            return self._amount > other._amount
-        else:
-            return self._amount > decimal.Decimal(str(other))
-
-    def __ge__(self, other):
-        return self > other or self == other
-
-    # Type conversion
-    def __int__(self):
-        return self._amount.__int__()
-
-    def __float__(self):
-        return self._amount.__float__()
-
-    # Pickle support
-    def __getstate__(self):
-        return {
-            'amount': self._amount,
-            'currency': self.currency.code,
-        }
-
-    def __setstate__(self, state):
-        self._amount = decimal_value(state.get('amount', '0.0'))
-
-    def _can_compare(self, right):
-        """
-        Check if we are able to compare right value.
-        
-        @raises Value Error if currencies do not match.
-        """
-        if isinstance(right, Money):
-            if self.currency == right.currency:
-                return True
-            else:
-                raise ValueError('Currencies do not match')
-        else:
-            return False
-
-    def format(self, places=2, currency_symbol='', separator=',', decimal_place='.', positive_sign='',
-               negative_sign='-', trailing_negative=''):
-        """
-        Format money value to a string
-        """
-        # Round and split number
-        q = decimal.Decimal(10) ** -places
-        sign, digits, exp = self._amount.quantize(q).as_tuple()
-
-        # Convert digits to string
-        results = []
-        digits = map(str, digits)
-        build, next = results.append, digits.pop
-
-        # Append trailing negative sign
-        if sign:
-            build(trailing_negative)
-
-        # Cut off decimal digits
-        if places:
-            for i in range(places):
-                build(next())
-            build(decimal_place)
-
-        # Append digits
-        if not digits:
-            build('0')
-        else:
-            idx = 0
-            while digits:
-                build(next())
-                idx += 1
-                if not idx % 3 and digits:
-                    build(str(separator))
-
-        # Append currency symbol and sign before returning result
-        build(str(currency_symbol))
-        build(str(negative_sign if sign else positive_sign))
-        return ''.join(reversed(results))