Commits

Tim Savage committed 208298f

Updated layout to match django itself.
Updated licence to match django
Added color picker widget
Added color field and validator

Comments (0)

Files changed (21)

+*~
+*.py[co]
+*.egg-info
+
+# IDE cruft
+.idea/*
-glob:*.pyc
-glob:*~
-glob:dev.db
-glob:venv/
+syntax: glob
+*~
+*.py[co]
+*.egg-info
 
-# Ignore artefacts produced by setuptools
-syntax: glob
-build
-dist
-*.egg-info/
+# IDE cruft
+.idea/*

.idea/jsLibraryMappings.xml

+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="JavaScriptLibraryMappings">
+    <excludedPredefinedLibrary name="AJAX" />
+    <excludedPredefinedLibrary name="DHTML" />
+    <excludedPredefinedLibrary name="DOM Core" />
+    <excludedPredefinedLibrary name="DOM Events" />
+    <excludedPredefinedLibrary name="DOM Traversal And Range" />
+    <excludedPredefinedLibrary name="DOM XPath" />
+    <excludedPredefinedLibrary name="EcmaScript" />
+    <excludedPredefinedLibrary name="EcmaScript Additional" />
+    <excludedPredefinedLibrary name="EcmaScript L5" />
+    <excludedPredefinedLibrary name="EcmaScript for XML" />
+    <excludedPredefinedLibrary name="HTML 5" />
+    <excludedPredefinedLibrary name="WebGL" />
+  </component>
+</project>
+
-Copyright (c) 2009, Tim Savage <tim.savage@poweredbypenguins.org>
+Copyright (c) Tim Savage <tim.savage@poweredbypenguins.org>.
 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.
-    * Neither the name of django-extras nor the
-      names of its contributors may be used to endorse or promote products
-      derived from this software without specific prior written permission.
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+
+    2. 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.
+
+    3. Neither the name of Django-Extras nor 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 TIM SAVAGE BE LIABLE FOR ANY
-DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS 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
+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.
+include README
+include LICENSE
+include MANIFEST.in
+recursive-include tests *
+recursive-include django_extras/static *

django_extras/__init__.py

-# Copyright 2011 Tim Savage <tim.savage@poweredbypenguins.org>
-# Licensed under the terms of the BSD License (see LICENSE)
+VERSION = (0, 1, 2, 'beta')
 
-VERSION = (0, 1, 2, 'beta')
-__version__ = '.'.join(map(str, VERSION))
+def get_version(*args, **kwargs):
+    # Don't litter django_extras/__init__.py with all the get_version stuff.
+    # Only import if it's actually called.
+    from django.utils.version import get_version
+    return get_version(*args, **kwargs)

django_extras/contrib/__init__.py

+__author__ = 'tsavage'

django_extras/contrib/auth/__init__.py

Empty file added.

django_extras/contrib/auth/decorators.py

+from django.contrib.auth.decorators import *
+
+
+def superuser_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None):
+    """
+    Decorator for views that checks that the user is a superuser, redirecting to
+    the log-in page if necessary.
+    """
+    actual_decorator = user_passes_test(
+        lambda u: u.is_superuser,
+        login_url=login_url,
+        redirect_field_name=redirect_field_name
+    )
+    if function:
+        return actual_decorator(function)
+    return actual_decorator
+
+def staff_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None):
+    """
+    Decorator for views that checks that the user is a staff member, redirecting
+    to the log-in page if necessary.
+    """
+    actual_decorator = user_passes_test(
+        lambda u: u.is_staff,
+        login_url=login_url,
+        redirect_field_name=redirect_field_name
+    )
+    if function:
+        return actual_decorator(function)
+    return actual_decorator

django_extras/core/__init__.py

+__author__ = 'tsavage'

django_extras/core/validators.py

+import re
+from django.core.validators import *
+from django.utils.translation import ugettext_lazy as _
+
+
+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
+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.
 
-    Items are defined with:
+    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'),
+            OPTION_TWO = ('value2', 'Verbose value 2', True), # Default, the value can be anything ;)
         )
 
-    They can then be used for choices with:
+    The less nice way to use ChoiceEnum (with guaranteed ordering):
 
-        MY_CHOICES.get_choices()
+        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
 
+    Convert a value to a display string:
+
+        MY_CHOICES % 'value'
+
     """
-    def __init__(self, **entries):
-        self._value_map = dict([(key, value) for (key, value) in entries.values()])
-        self._choices = entries
+    __slots__ = ('__default', '__value_map', '__choices', '__max_length')
 
-    def get_choices(self):
+    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 self._choices.values()
-
-    def resolve_display(self, value):
-        """
-        Resolve a value to it's display version.
-        """
-        return self._value_map[value]
+        return iter(self.__choices.values())
 
     def __contains__(self, value):
         """
 
         @param value Choice value.
         """
-        return value in self._value_map.keys()
+        return value in self.__value_map
 
     def __getattr__(self, item):
         """
         Get the value of an Enum.
-
-        Append __display to get display version of the item.
         """
         if item.endswith('__display'):
-            return self._choices[item[:-9]][1]
+            return self.__choices[item[:-9]][1]
         else:
-            return self._choices[item][0]
+            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/decorators.py

-from django.contrib.auth import REDIRECT_FIELD_NAME
-from django.contrib.auth.decorators import user_passes_test
-
-def staff_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None):
-    """
-    Decorator for views that checks that the user is logged in, redirecting
-    to the log-in page if necessary.
-    """
-    actual_decorator = user_passes_test(
-        lambda u: u.is_staff,
-        login_url=login_url,
-        redirect_field_name=redirect_field_name
-    )
-    if function:
-        return actual_decorator(function)
-    return actual_decorator

django_extras/forms/__init__.py

+from widgets import *

django_extras/forms/widgets.py

+from django.forms.widgets import *
+from django.conf import settings
+
+
+class ColorPickerWidget(TextInput):
+    """
+    Color picker widget.
+    """
+    class Media:
+        js = (settings.STATIC_URL + 'js/jquery.colorpicker.js', )
+
+    def __init__(self, attrs={}):
+        attrs.set_default('class', 'vColorField')
+        super(ColorPickerWidget, self).__init__(attrs=attrs)

django_extras/http/__init__.py

+import os.path
+
 from django.core.serializers import json
 from django.utils import simplejson
-from django.http import HttpResponse
+from django.http import *
 
 
 class HttpResponseUnAuthorised(HttpResponse):
     status_code = 401
 
+
 class HttpResponseConflict(HttpResponse):
     status_code = 409
 
+
 class HttpResponseNotImplemented(HttpResponse):
     status_code = 501
 
+
 class HttpResponseGatewayTimeout(HttpResponse):
     status_code = 504
 
         super(JsonResponse, self).__init__(
             simplejson.dumps(data, cls=json.DjangoJSONEncoder),
             content_type=content_type, **kwargs)
-            

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))
-#!/usr/bin/env python
-from setuptools import setup, find_packages
-from os.path import join, dirname
-import django_extras
+from distutils.core import setup
+from distutils.command.install_data import install_data
+from distutils.command.install import INSTALL_SCHEMES
+from distutils.sysconfig import get_python_lib
+import os
+import sys
 
-if 'final' in django_extras.VERSION[-1]:
-    CLASSIFIERS = ['Development Status :: 5 - Stable']
-elif 'beta' in django_extras.VERSION[-1]:
-    CLASSIFIERS = ['Development Status :: 4 - Beta']
-else:
-    CLASSIFIERS = ['Development Status :: 3 - Alpha']
-CLASSIFIERS += [
-    'Environment :: Web Environment',
-    'Framework :: Django',
-    'Intended Audience :: Developers',
-    'Operating System :: OS Independent',
-    'Programming Language :: Python',
-    'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
-    'Topic :: Software Development',
-    'Topic :: Software Development :: Libraries :: Application Frameworks',
-]
+def fullsplit(path, result=None):
+    """
+    Split a pathname into components (the opposite of os.path.join) in a
+    platform-neutral way.
+    """
+    if result is None:
+        result = []
+    head, tail = os.path.split(path)
+    if head == '':
+        return [tail] + result
+    if head == path:
+        return result
+    return fullsplit(head, [tail] + result)
+    
+# Compile the list of packages available, because distutils doesn't have
+# an easy way to do this.
+packages, data_files = [], []
+root_dir = os.path.dirname(__file__)
+if root_dir != '':
+    os.chdir(root_dir)
+django_extras_dir = 'django_extras'
+
+for dirpath, dirnames, filenames in os.walk(django_extras_dir):
+    # Ignore dirnames that start with '.'
+    for i, dirname in enumerate(dirnames):
+        if dirname.startswith('.'): del dirnames[i]
+    if '__init__.py' in filenames:
+        packages.append('.'.join(fullsplit(dirpath)))
+    elif filenames:
+        data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]])
+
+# Dynamically calculate the version based on django_extras.VERSION.
+version = __import__('django_extras').get_version()
 
 setup(
-    name = "django-extras",
-    version = django_extras.__version__,
+    name = "Django-Extras",
+    version = version,
     url = "https://bitbucket.org/timsavage/django-extras",
     author = "Tim Savage",
     author_email = "tim.savage@poweredbypenguins.org",
-    license = "BSD License",
-    description = "A selection of extra features for django that solve common annoyances and limitations.",
-    long_description=open(join(dirname(__file__), 'README')).read(),
-    classifiers=CLASSIFIERS,
-    platforms=['OS Independent'],
-    packages=find_packages(exclude=["example", "example.*"]),
-    zip_safe = False,
+    description = "A selection of extras for the Django Web framework.",
+    packages = packages,
+    data_files = data_files,
+    requires = ['django'],
+    classifiers = [
+        'Development Status :: 4 - Beta',
+        'Environment :: Web Environment',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: BSD License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        'Programming Language :: Python :: 2.6',
+        'Programming Language :: Python :: 2.7',
+        'Topic :: Internet :: WWW/HTTP',
+        'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
+        'Topic :: Internet :: WWW/HTTP :: WSGI',
+        'Topic :: Software Development :: Libraries :: Application Frameworks',
+        'Topic :: Software Development :: Libraries :: Python Modules',
+    ]
 )

tests/__init__.py

+__author__ = 'tsavage'
+#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"))
+