├── .venv ├── djmoney ├── templatetags │ ├── __init__.py │ └── djmoney.py ├── tests │ ├── testapp │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── forms.py │ │ └── models.py │ ├── __init__.py │ ├── money_patched.py │ ├── reversion_tests.py │ ├── settings.py │ ├── tags_tests.py │ ├── form_tests.py │ ├── managers_tests.py │ └── model_tests.py ├── forms │ ├── __init__.py │ ├── widgets.py │ └── fields.py ├── utils.py ├── models │ ├── __init__.py │ ├── managers.py │ └── fields.py ├── settings.py ├── serializers.py └── __init__.py ├── pytest.ini ├── MANIFEST.in ├── .gitignore ├── gen_travis.bash ├── MANIFEST ├── .travis.yml ├── tox.ini ├── LICENSE.txt ├── setup.py ├── runtests.py ├── CHANGES.txt ├── gen_tox.bash └── README.rst /.venv: -------------------------------------------------------------------------------- 1 | django-money 2 | -------------------------------------------------------------------------------- /djmoney/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /djmoney/tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /djmoney/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import * 2 | from .widgets import * 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files=mone*.py 3 | DJANGO_SETTINGS_MODULE=settings 4 | -------------------------------------------------------------------------------- /djmoney/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | get_currency_field_name = lambda name: "%s_currency" % name 4 | -------------------------------------------------------------------------------- /djmoney/models/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core import serializers as serializers 2 | 3 | serializers.register_serializer("json", 'djmoney.serializers') 4 | -------------------------------------------------------------------------------- /djmoney/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .model_tests import * 2 | from .form_tests import * 3 | from .tags_tests import * 4 | from .managers_tests import * 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py 2 | include CHANGES.txt 3 | include LICENSE.txt 4 | include README.rst 5 | include runtests.py 6 | recursive-include djmoney *.py *.html *.txt *.png *.js *.css *.gif *.less *.mo *.po *.otf *.svg *.woff *.eot *.ttf 7 | -------------------------------------------------------------------------------- /djmoney/tests/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | import models 4 | 5 | class InheritedModelAdmin(admin.ModelAdmin): 6 | readonly_fields = ('second_field',) 7 | 8 | admin.site.register(models.InheritedModel, InheritedModelAdmin) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.~ 4 | .installed.cfg 5 | bin 6 | develop-eggs 7 | dist 8 | downloads 9 | eggs 10 | parts 11 | *.egg-info 12 | build 13 | .ropeproject 14 | .tox 15 | __pycache__ 16 | 17 | .project 18 | .pydevproject 19 | .settings 20 | djmoney/tests/testproject 21 | .tox 22 | .idea 23 | .venv 24 | *\.egg 25 | *\.egg-info 26 | .cache 27 | -------------------------------------------------------------------------------- /gen_travis.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cat <<"END" 4 | language: python 5 | python: 6 | - 2.7 7 | install: pip install tox 8 | script: tox -e $ENV 9 | env: 10 | END 11 | 12 | if [[ $1 == "--all" ]] ; then 13 | tox --list | ( 14 | while read i ; do 15 | echo " - ENV=$i" 16 | done 17 | ) 18 | else 19 | # a few representative envs... 20 | cat <<"END" 21 | - ENV=py27-django15-pmlatest 22 | - ENV=py27-django16-pmlatest 23 | - ENV=py33-django15-pmlatest 24 | - ENV=py33-django16-pmlatest 25 | END 26 | fi 27 | -------------------------------------------------------------------------------- /djmoney/tests/money_patched.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 2 | from moneyed import test_moneyed_classes 3 | from djmoney.models.fields import MoneyPatched 4 | 5 | 6 | class TestDjangoMoney(test_moneyed_classes.TestMoney): 7 | 8 | # MoneyPatched localizes to 'en_US', removing "US" from the default 9 | # py-moneyed prefix, 'US$'. This breaks py-moneyed's test_str. 10 | def test_str(self): 11 | assert str(self.one_million_bucks) == '$1,000,000.00' 12 | 13 | # replace class "Money" a class "MoneyPath" 14 | test_moneyed_classes.Money = MoneyPatched 15 | 16 | TestCurrency = test_moneyed_classes.TestCurrency 17 | 18 | TestMoney = TestDjangoMoney 19 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | CHANGES.txt 3 | LICENSE.txt 4 | README.md 5 | runtests.py 6 | setup.py 7 | djmoney/__init__.py 8 | djmoney/serializers.py 9 | djmoney/utils.py 10 | djmoney/forms/__init__.py 11 | djmoney/forms/fields.py 12 | djmoney/forms/widgets.py 13 | djmoney/models/__init__.py 14 | djmoney/models/fields.py 15 | djmoney/models/managers.py 16 | djmoney/tests/__init__.py 17 | djmoney/tests/form_tests.py 18 | djmoney/tests/model_tests.py 19 | djmoney/tests/reversion_tests.py 20 | djmoney/tests/runtests.py 21 | djmoney/tests/settings.py 22 | djmoney/tests/testapp/__init__.py 23 | djmoney/tests/testapp/forms.py 24 | djmoney/tests/testapp/models.py 25 | -------------------------------------------------------------------------------- /djmoney/tests/reversion_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from testapp.models import RevisionedModel 3 | from moneyed import Money 4 | import reversion 5 | 6 | 7 | class ReversionTestCase(TestCase): 8 | def test_that_can_safely_restore_deleted_object(self): 9 | model = None 10 | amount = Money(100, 'GHS') 11 | with reversion.create_revision(): 12 | model = RevisionedModel.objects.create(amount=amount) 13 | model.save() 14 | model.delete() 15 | version = reversion.get_deleted(RevisionedModel)[0] 16 | version.revision.revert() 17 | model = RevisionedModel.objects.get(pk=1) 18 | self.assertEquals(model.amount, amount) 19 | -------------------------------------------------------------------------------- /djmoney/tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on May 15, 2011 3 | 4 | @author: jake 5 | ''' 6 | 7 | from django import forms 8 | from djmoney.forms import MoneyField 9 | from .models import ModelWithVanillaMoneyField 10 | 11 | 12 | class MoneyForm(forms.Form): 13 | 14 | money = MoneyField(currency_choices=[(u'SEK', u'Swedish Krona')], max_value=1000, min_value=2) 15 | 16 | class MoneyFormMultipleCurrencies(forms.Form): 17 | 18 | money = MoneyField(currency_choices=[(u'SEK', u'Swedish Krona'), (u'EUR', u'Euro')], max_value=1000, min_value=2) 19 | 20 | class OptionalMoneyForm(forms.Form): 21 | 22 | money = MoneyField(required=False, currency_choices=[(u'SEK', u'Swedish Krona')]) 23 | 24 | class MoneyModelForm(forms.ModelForm): 25 | 26 | class Meta: 27 | model = ModelWithVanillaMoneyField 28 | fields = ('money',) 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: pip install tox 3 | script: tox -e $ENV 4 | matrix: 5 | include: 6 | - python: "2.6" 7 | env: ENV=py26-django15-pm05 8 | - python: "2.6" 9 | env: ENV=py26-django16-pm05 10 | - python: "2.7" 11 | env: ENV=py27-django15-pm05 12 | - python: "2.7" 13 | env: ENV=py27-django16-pm05 14 | - python: "2.7" 15 | env: ENV=py27-django17-pm05 16 | - python: "2.7" 17 | env: ENV=py27-django18-pm05 18 | - python: "3.3" 19 | env: ENV=py33-django15-pm05 20 | - python: "3.3" 21 | env: ENV=py33-django16-pm05 22 | - python: "3.3" 23 | env: ENV=py33-django17-pm05 24 | - python: "3.3" 25 | env: ENV=py33-django18-pm05 26 | - python: "3.4" 27 | env: ENV=py34-django17-pm05 28 | - python: "3.4" 29 | env: ENV=py34-django18-pm05 30 | -------------------------------------------------------------------------------- /djmoney/settings.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from django.conf import settings 4 | from moneyed import CURRENCIES, DEFAULT_CURRENCY_CODE, DEFAULT_CURRENCY 5 | 6 | # The default currency, you can define this in your project's settings module 7 | # This has to be a currency object imported from moneyed 8 | DEFAULT_CURRENCY = getattr(settings, 'DEFAULT_CURRENCY', DEFAULT_CURRENCY) 9 | 10 | 11 | # The default currency choices, you can define this in your project's 12 | # settings module 13 | PROJECT_CURRENCIES = getattr(settings, 'CURRENCIES', None) 14 | 15 | if PROJECT_CURRENCIES: 16 | CURRENCY_CHOICES = [(code, CURRENCIES[code].name) for code in 17 | PROJECT_CURRENCIES] 18 | else: 19 | CURRENCY_CHOICES = [(c.code, c.name) for i, c in CURRENCIES.items() if 20 | c.code != DEFAULT_CURRENCY_CODE] 21 | 22 | CURRENCY_CHOICES.sort(key=operator.itemgetter(1)) 23 | -------------------------------------------------------------------------------- /djmoney/tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | import warnings 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': ':memory:', 9 | } 10 | } 11 | 12 | warnings.simplefilter('ignore', Warning) 13 | 14 | INSTALLED_APPS = ( 15 | 'django.contrib.admin', 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.sessions', 19 | 'django.contrib.sites', 20 | 'djmoney', 21 | 'testapp' 22 | ) 23 | 24 | SITE_ID = 1 25 | ROOT_URLCONF = 'core.urls' 26 | 27 | SECRET_KEY = 'foobar' 28 | 29 | USE_L10N = True 30 | 31 | import moneyed 32 | from moneyed.localization import _FORMATTER, DEFAULT 33 | from decimal import ROUND_HALF_EVEN 34 | 35 | _FORMATTER.add_sign_definition('pl_PL', moneyed.PLN, suffix=' zł') 36 | _FORMATTER.add_sign_definition(DEFAULT, moneyed.PLN, suffix=' zł') 37 | _FORMATTER.add_formatting_definition( 38 | "pl_PL", group_size=3, group_separator=" ", decimal_point=",", 39 | positive_sign="", trailing_positive_sign="", 40 | negative_sign="-", trailing_negative_sign="", 41 | rounding_method=ROUND_HALF_EVEN) 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{26,27,py}-django{14,15,16}-pm{04,05}, 4 | py{32,33}-django{15,16}-pm05, 5 | py27-django{17,18,_master}-pm04, 6 | py{32,33,34,py}-django{17,18,_master}-pm05, 7 | 8 | [testenv] 9 | basepython = 10 | py26: python2.6 11 | py27: python2.7 12 | py32: python3.2 13 | py33: python3.3 14 | py34: python3.4 15 | pypy: pypy 16 | deps = 17 | django14: {[django]1.4.x} 18 | django15: {[django]1.5.x} 19 | django16: {[django]1.6.x} 20 | django17: {[django]1.7.x} 21 | django18: {[django]1.8.x} 22 | django_master: {[django]master} 23 | py26: unittest2 24 | pm04: py-moneyed==0.4.1 25 | pm05: py-moneyed==0.5.0 26 | pmlatest: py-moneyed 27 | pytest-django>=2.3.0 28 | mock 29 | commands = {envpython} runtests.py {posargs} 30 | 31 | [django] 32 | 1.4.x = 33 | Django==1.4.20 34 | django-reversion==1.6.6 35 | south>=0.8.2 36 | 1.5.x = 37 | Django==1.5.12 38 | django-reversion==1.7.1 39 | south>=0.8.2 40 | 1.6.x = 41 | Django==1.6.11 42 | django-reversion==1.8.5 43 | south>=0.8.2 44 | 1.7.x = 45 | Django==1.7.8 46 | django-reversion==1.8.5 47 | 1.8.x = 48 | Django==1.8.2 49 | django-reversion==1.8.5 50 | master = 51 | https://github.com/django/django/tarball/master 52 | django-reversion==1.8.5 53 | -------------------------------------------------------------------------------- /djmoney/serializers.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import json 3 | 4 | from django.core.serializers.python import Deserializer as PythonDeserializer 5 | from django.core.serializers.json import Serializer as JSONSerializer 6 | from django.core.serializers.python import _get_model 7 | from django.utils import six 8 | 9 | from djmoney.models.fields import MoneyField 10 | from djmoney.utils import get_currency_field_name 11 | from moneyed import Money 12 | 13 | Serializer = JSONSerializer 14 | 15 | 16 | def Deserializer(stream_or_string, **options): 17 | """ 18 | Deserialize a stream or string of JSON data. 19 | """ 20 | if not isinstance(stream_or_string, (bytes, six.string_types)): 21 | stream_or_string = stream_or_string.read() 22 | if isinstance(stream_or_string, bytes): 23 | stream_or_string = stream_or_string.decode('utf-8') 24 | try: 25 | for obj in json.loads(stream_or_string): 26 | money_fields = {} 27 | fields = {} 28 | Model = _get_model(obj["model"]) 29 | for (field_name, field_value) in six.iteritems(obj['fields']): 30 | field = Model._meta.get_field(field_name) 31 | if isinstance(field, MoneyField) and field_value is not None: 32 | money_fields[field_name] = Money(field_value, obj['fields'][get_currency_field_name(field_name)]) 33 | else: 34 | fields[field_name] = field_value 35 | obj['fields'] = fields 36 | 37 | for obj in PythonDeserializer([obj], **options): 38 | for field, value in money_fields.items(): 39 | setattr(obj.object, field, value) 40 | yield obj 41 | except GeneratorExit: 42 | raise 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | = Jacob Hansson 2 | = Voltvoodoo 3 | = 2011 4 | 5 | In the original BSD license, both occurrences of the phrase "COPYRIGHT HOLDERS AND CONTRIBUTORS" in the disclaimer read "REGENTS AND CONTRIBUTORS". 6 | 7 | Here is the license template: 8 | 9 | Copyright (c) , 10 | All rights reserved. 11 | 12 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 13 | 14 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 15 | * 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. 16 | * Neither the name of the nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 17 | 18 | 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 THE COPYRIGHT HOLDER 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 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #-*- encoding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | from setuptools.command.test import test as TestCommand 5 | import sys 6 | 7 | class Tox(TestCommand): 8 | def finalize_options(self): 9 | TestCommand.finalize_options(self) 10 | self.test_args = [] 11 | self.test_suite = True 12 | def run_tests(self): 13 | #import here, cause outside the eggs aren't loaded 14 | import tox 15 | errno = tox.cmdline(self.test_args) 16 | sys.exit(errno) 17 | 18 | 19 | setup(name="django-money", 20 | version="0.7.3", 21 | description="Adds support for using money and currency fields in django models and forms. Uses py-moneyed as the money implementation.", 22 | url="https://github.com/jakewins/django-money", 23 | maintainer='Greg Reinbach', 24 | maintainer_email='greg@reinbach.com', 25 | packages=["djmoney", 26 | "djmoney.forms", 27 | "djmoney.models", 28 | "djmoney.templatetags", 29 | "djmoney.tests"], 30 | install_requires=['setuptools', 31 | 'Django >= 1.4, < 1.9', 32 | 'py-moneyed > 0.4', 33 | 'six'], 34 | platforms=['Any'], 35 | keywords=['django', 'py-money', 'money'], 36 | classifiers=["Development Status :: 5 - Production/Stable", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: BSD License", 39 | "Operating System :: OS Independent", 40 | "Programming Language :: Python", 41 | "Programming Language :: Python :: 3", 42 | "Framework :: Django", ], 43 | tests_require=['tox>=1.6.0'], 44 | cmdclass={'test': Tox}, 45 | ) 46 | -------------------------------------------------------------------------------- /djmoney/forms/widgets.py: -------------------------------------------------------------------------------- 1 | from django.forms import TextInput, Select, MultiWidget 2 | from ..settings import CURRENCY_CHOICES 3 | 4 | 5 | __all__ = ('MoneyWidget',) 6 | 7 | 8 | class MoneyWidget(MultiWidget): 9 | def __init__(self, choices=CURRENCY_CHOICES, amount_widget=None, currency_widget=None, *args, **kwargs): 10 | if not amount_widget: 11 | amount_widget = TextInput 12 | if not currency_widget: 13 | currency_widget = Select(choices=choices) 14 | widgets = (amount_widget, currency_widget) 15 | super(MoneyWidget, self).__init__(widgets, *args, **kwargs) 16 | 17 | def decompress(self, value): 18 | if value is not None: 19 | return [value.amount, value.currency] 20 | return [None, None] 21 | 22 | # Needed for Django 1.5.x, where Field doesn't have the '_has_changed' method. 23 | # But it mustn't run on Django 1.6, where it doesn't work and isn't needed. 24 | 25 | if hasattr(TextInput, '_has_changed'): 26 | # This is a reimplementation of the MoneyField.has_changed, 27 | # but for the widget. 28 | def _has_changed(self, initial, data): 29 | if initial is None: 30 | initial = ['' for x in range(0, len(data))] 31 | else: 32 | if not isinstance(initial, list): 33 | initial = self.decompress(initial) 34 | 35 | amount_widget, currency_widget = self.widgets 36 | amount_initial, currency_initial = initial 37 | 38 | try: 39 | amount_data = data[0] 40 | except IndexError: 41 | amount_data = None 42 | 43 | if amount_widget._has_changed(amount_initial, amount_data): 44 | return True 45 | 46 | try: 47 | currency_data = data[1] 48 | except IndexError: 49 | currency_data = None 50 | 51 | if currency_widget._has_changed(currency_initial, currency_data) and amount_data: 52 | return True 53 | 54 | return False 55 | -------------------------------------------------------------------------------- /djmoney/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | try: 3 | from django.utils.encoding import smart_unicode 4 | except ImportError: 5 | # Python 3 6 | from django.utils.encoding import smart_text as smart_unicode 7 | 8 | try: 9 | from django.utils.timezone import localtime 10 | except ImportError: 11 | def localtime(value): 12 | return value 13 | 14 | from django.core.exceptions import ObjectDoesNotExist 15 | try: 16 | from django.contrib.admin.utils import lookup_field 17 | except ImportError: 18 | from django.contrib.admin.util import lookup_field 19 | from django.utils.safestring import mark_safe 20 | from django.utils.html import conditional_escape 21 | from django.db.models.fields.related import ManyToManyRel 22 | 23 | 24 | def djmoney_contents(self): 25 | from django.contrib.admin.templatetags.admin_list import _boolean_icon 26 | from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE 27 | 28 | field, obj, model_admin = self.field['field'], self.form.instance, self.model_admin 29 | 30 | try: 31 | f, attr, value = lookup_field(field, obj, model_admin) 32 | except (AttributeError, ValueError, ObjectDoesNotExist): 33 | result_repr = EMPTY_CHANGELIST_VALUE 34 | else: 35 | if f is None: 36 | boolean = getattr(attr, "boolean", False) 37 | if boolean: 38 | result_repr = _boolean_icon(value) 39 | else: 40 | result_repr = smart_unicode(value) 41 | if getattr(attr, "allow_tags", False): 42 | result_repr = mark_safe(result_repr) 43 | else: 44 | if value is None: 45 | result_repr = EMPTY_CHANGELIST_VALUE 46 | elif isinstance(f.rel, ManyToManyRel): 47 | result_repr = ", ".join(map(str, value.all())) 48 | else: 49 | result_repr = smart_unicode(value) 50 | return conditional_escape(result_repr) 51 | 52 | 53 | from django.contrib.admin.helpers import AdminReadonlyField 54 | 55 | AdminReadonlyField.contents = djmoney_contents 56 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | import sys 5 | from django.conf import settings 6 | 7 | # Detect if django.db.migrations is supported 8 | from django import VERSION as DJANGO_VERSION 9 | NATIVE_MIGRATIONS = (DJANGO_VERSION >= (1, 7)) 10 | 11 | 12 | INSTALLED_APPS = ( 13 | 'django.contrib.auth', 14 | 'django.contrib.contenttypes', 15 | 'djmoney', 16 | 'djmoney.tests.testapp', 17 | 'reversion', 18 | ) 19 | 20 | if not NATIVE_MIGRATIONS: 21 | INSTALLED_APPS += ( 22 | 'south', 23 | ) 24 | 25 | settings.configure( 26 | DEBUG=True, 27 | DATABASES={ 28 | 'default': { 29 | 'ENGINE': 'django.db.backends.sqlite3', 30 | } 31 | }, 32 | SITE_ID=1, 33 | ROOT_URLCONF=None, 34 | MIDDLEWARE_CLASSES=( 35 | 'django.middleware.common.CommonMiddleware', 36 | ), 37 | INSTALLED_APPS=INSTALLED_APPS, 38 | USE_TZ=True, 39 | USE_L10N=True, 40 | SOUTH_TESTS_MIGRATE=True, 41 | ) 42 | 43 | import moneyed 44 | from moneyed.localization import _FORMATTER, DEFAULT 45 | from decimal import ROUND_HALF_EVEN 46 | 47 | _FORMATTER.add_sign_definition('pl_PL', moneyed.PLN, suffix=' zł') 48 | _FORMATTER.add_sign_definition(DEFAULT, moneyed.PLN, suffix=' zł') 49 | _FORMATTER.add_formatting_definition( 50 | "pl_PL", group_size=3, group_separator=" ", decimal_point=",", 51 | positive_sign="", trailing_positive_sign="", 52 | negative_sign="-", trailing_negative_sign="", 53 | rounding_method=ROUND_HALF_EVEN) 54 | 55 | 56 | try: 57 | from django.test.simple import DjangoTestSuiteRunner 58 | except ImportError: 59 | from django.test.runner import DiscoverRunner as DjangoTestSuiteRunner 60 | 61 | test_runner = DjangoTestSuiteRunner(verbosity=1, failfast=False) 62 | 63 | # Native migrations are present in Django 1.7+ 64 | # This also requires initializing the app registry with django.setup() 65 | # If native migrations are not present, initialize South and configure it for running the test suite 66 | if NATIVE_MIGRATIONS: 67 | from django import setup 68 | setup() 69 | else: 70 | from south.management.commands import patch_for_test_db_setup 71 | patch_for_test_db_setup() 72 | 73 | if len(sys.argv) > 1: 74 | tests = sys.argv[1:] 75 | else: 76 | tests = ['djmoney'] 77 | failures = test_runner.run_tests(tests) 78 | if failures: 79 | sys.exit(failures) 80 | 81 | 82 | ## Run py.tests 83 | # Compatibility testing patches on the py-moneyed 84 | import pytest 85 | failures = pytest.main() 86 | 87 | if failures: 88 | sys.exit(failures) 89 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Changes in 0.7.3 2 | - Sum different currencies (FeverUp) 3 | - Added __eq__ method (@benjaoming) 4 | - Comparison of different currencies (@benjaoming) 5 | - Default currency (@benjaoming) 6 | - Fix using Choices for setting currency choices (@benjaoming) 7 | - Fix tests for python 2.6 (@plumdog) 8 | 9 | Changes in 0.7.2 10 | - Better checks on None values (@tsouvarev, @sjdines) 11 | - Consistency with south declarations and calling str function (@sjdines) 12 | 13 | Changes in 0.7 14 | - Django 1.8 compatible (@willhcr) 15 | - Fix bug in printing MoneyField (@YAmikep) 16 | 17 | Changes in 0.6 18 | - Tox cleanup (@edwinlunando) 19 | - Add Python 3 trove classifier (@dekkers) 20 | - Improved README (@glarrain) 21 | - Appends _currency to non-money ExpressionFields [#101] (@alexhayes, @alexriina, @briankung) 22 | - Data truncated for column [#103] (@alexhayes) 23 | - Proxy Model with MoneyField returns wrong class [#80] (@spookylukey) 24 | - Fixed has_changed not working [#95] (@spookylukey) 25 | -Added/Cleaned up tests (@spookylukey, @alexriina) 26 | 27 | Changes in 0.5 28 | - Django 1.7 compatibility (Francois Rejete ) 29 | - Added "choices=" to instantiation of currency widget (David Stockwell ) 30 | - Nullable MoneyField should act as default=None (Jacob Hansson ) 31 | - Fixed bug where a non-required MoneyField threw an exception (@spookylukey) 32 | 33 | Changes in 0.4 34 | - Python 3 compatibility 35 | - add tox tests 36 | - add format localization 37 | - tag money_localize 38 | 39 | Changes in 0.3.3 40 | - Fixed issues with money widget not passing attrs up to django's render method, caused id attribute to not be set in html for widgets (adambregenzer) 41 | - Fixed issue of default currency not being passed on to widget (snbuchholz) 42 | - Implemented the south_triple_field to add support for south migration (mattions) 43 | - Return the right default for south (mattions) 44 | - Django 1.5 compatibility fix (devlocal) 45 | 46 | Changes in 0.3.2 47 | - Fixed issues with display_for_field not detecting fields correctly (adambregenzer) 48 | - Add south ignore rule to avoid duplicate currency field when using the frozen orm (Rachid Belaid ) 49 | - Disallow overide of objects manager if not setting it up with an instance (Rachid Belaid ) 50 | 51 | Changes in 0.3.1 52 | - Fix AttributeError when Model inherit a manager (Rachid Belaid ) 53 | - Correctly serialize the field (Anand Kumria ) 54 | 55 | Changes in 0.3 56 | - Allow django-money to be specified as read-only in a model (Anand Kumria ) 57 | - South support: Declare default attribute values. (Piet Delport ) 58 | -------------------------------------------------------------------------------- /djmoney/tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on May 7, 2011 3 | 4 | @author: jake 5 | ''' 6 | 7 | from djmoney.models.fields import MoneyField 8 | from django.db import models 9 | 10 | import moneyed 11 | from decimal import Decimal 12 | 13 | 14 | class ModelWithVanillaMoneyField(models.Model): 15 | money = MoneyField(max_digits=10, decimal_places=2) 16 | 17 | class ModelWithDefaultAsInt(models.Model): 18 | money = MoneyField(default=123, max_digits=10, decimal_places=2, default_currency='GHS') 19 | 20 | class ModelWithDefaultAsStringWithCurrency(models.Model): 21 | money = MoneyField(default='123 USD', max_digits=10, decimal_places=2) 22 | 23 | class Meta: 24 | verbose_name = 'model_default_string_currency' 25 | 26 | class ModelWithDefaultAsString(models.Model): 27 | money = MoneyField(default='123', max_digits=10, decimal_places=2, default_currency='PLN') 28 | 29 | class ModelWithDefaultAsFloat(models.Model): 30 | money = MoneyField(default=12.05, max_digits=10, decimal_places=2, default_currency='PLN') 31 | 32 | class ModelWithDefaultAsDecimal(models.Model): 33 | money = MoneyField(default=Decimal('0.01'), max_digits=10, decimal_places=2, default_currency='CHF') 34 | 35 | class ModelWithDefaultAsMoney(models.Model): 36 | money = MoneyField(default=moneyed.Money('0.01', 'RUB'), max_digits=10, decimal_places=2) 37 | 38 | class ModelWithTwoMoneyFields(models.Model): 39 | amount1 = MoneyField(max_digits=10, decimal_places=2) 40 | amount2 = MoneyField(max_digits=10, decimal_places=3) 41 | 42 | class ModelRelatedToModelWithMoney(models.Model): 43 | moneyModel = models.ForeignKey(ModelWithVanillaMoneyField) 44 | 45 | 46 | class ModelWithChoicesMoneyField(models.Model): 47 | money = MoneyField( 48 | max_digits=10, 49 | decimal_places=2, 50 | currency_choices=[ 51 | (moneyed.USD, 'US Dollars'), 52 | (moneyed.ZWN, 'Zimbabwian') 53 | ], 54 | ) 55 | 56 | 57 | class ModelWithNonMoneyField(models.Model): 58 | money = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 59 | desc = models.CharField(max_length=10) 60 | 61 | 62 | class AbstractModel(models.Model): 63 | price1 = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 64 | 65 | class Meta: 66 | abstract = True 67 | 68 | 69 | class InheritorModel(AbstractModel): 70 | price2 = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 71 | 72 | 73 | class RevisionedModel(models.Model): 74 | amount = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 75 | 76 | import reversion 77 | reversion.register(RevisionedModel) 78 | 79 | 80 | class BaseModel(models.Model): 81 | first_field = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 82 | 83 | 84 | class InheritedModel(BaseModel): 85 | second_field = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 86 | 87 | 88 | class SimpleModel(models.Model): 89 | money = MoneyField(max_digits=10, decimal_places=2, default_currency='USD') 90 | 91 | 92 | class NullMoneyFieldModel(models.Model): 93 | field = MoneyField(max_digits=10, decimal_places=2, null=True) 94 | 95 | 96 | class ProxyModel(SimpleModel): 97 | class Meta: 98 | proxy = True 99 | -------------------------------------------------------------------------------- /djmoney/tests/tags_tests.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from django.test import TestCase 4 | from django import template 5 | from django.utils import translation 6 | 7 | from ..models.fields import MoneyPatched 8 | from moneyed import Money 9 | 10 | 11 | class MoneyLocalizeTestCase(TestCase): 12 | 13 | def setUp(self): 14 | self.default_language = translation.get_language() 15 | translation.activate('pl') 16 | super(TestCase, self).setUp() 17 | 18 | def tearDown(self): 19 | translation.activate(self.default_language) 20 | super(TestCase, self).tearDown() 21 | 22 | def assertTemplate(self, template_string, result, context={}): 23 | c = template.Context(context) 24 | t = template.Template(template_string) 25 | self.assertEqual(t.render(c), result) 26 | 27 | def testOnOff(self): 28 | 29 | # with a tag template "money_localize" 30 | self.assertTemplate( 31 | '{% load djmoney %}{% money_localize money %}', 32 | '2,30 zł', 33 | context={'money': Money(2.3, 'PLN')}) 34 | 35 | # without a tag template "money_localize" 36 | self.assertTemplate( 37 | '{{ money }}', 38 | '2,30 zł', 39 | context={'money': MoneyPatched(2.3, 'PLN')}) 40 | 41 | with self.settings(USE_L10N=False): 42 | # money_localize has a default setting USE_L10N = True 43 | self.assertTemplate( 44 | '{% load djmoney %}{% money_localize money %}', 45 | '2,30 zł', 46 | context={'money': Money(2.3, 'PLN')}) 47 | 48 | # without a tag template "money_localize" 49 | self.assertTemplate( 50 | '{{ money }}', 51 | '2.30 zł', 52 | context={'money': MoneyPatched(2.3, 'PLN')}) 53 | mp = MoneyPatched(2.3, 'PLN') 54 | mp.use_l10n = True 55 | self.assertTemplate( 56 | '{{ money }}', 57 | '2,30 zł', 58 | context={'money': mp}) 59 | 60 | self.assertTemplate( 61 | '{% load djmoney %}{% money_localize money on %}', 62 | '2,30 zł', 63 | context={'money': Money(2.3, 'PLN')}) 64 | 65 | with self.settings(USE_L10N=False): 66 | self.assertTemplate( 67 | '{% load djmoney %}{% money_localize money on %}', 68 | '2,30 zł', 69 | context={'money': Money(2.3, 'PLN')}) 70 | 71 | self.assertTemplate( 72 | '{% load djmoney %}{% money_localize money off %}', 73 | '2.30 zł', 74 | context={'money': Money(2.3, 'PLN')}) 75 | 76 | def testAsVar(self): 77 | 78 | self.assertTemplate( 79 | '{% load djmoney %}{% money_localize money as NEW_M %}{{NEW_M}}', 80 | '2,30 zł', 81 | context={'money': Money(2.3, 'PLN')}) 82 | 83 | self.assertTemplate( 84 | '{% load djmoney %}{% money_localize money off as NEW_M %}{{NEW_M}}', 85 | '2.30 zł', 86 | context={'money': Money(2.3, 'PLN')}) 87 | 88 | # test zero amount of money 89 | self.assertTemplate( 90 | '{% load djmoney %}{% money_localize money off as NEW_M %}{{NEW_M}}', 91 | '0.00 zł', 92 | context={'money': Money(0, 'PLN')}) 93 | 94 | def testConvert(self): 95 | 96 | self.assertTemplate( 97 | '{% load djmoney %}{% money_localize "2.5" "PLN" as NEW_M %}{{NEW_M}}', 98 | '2,50 zł', 99 | context={}) 100 | 101 | self.assertTemplate( 102 | '{% load djmoney %}{% money_localize "2.5" "PLN" %}', 103 | '2,50 zł', 104 | context={}) 105 | 106 | self.assertTemplate( 107 | '{% load djmoney %}{% money_localize amount currency %}', 108 | '2,60 zł', 109 | context={'amount': 2.6, 'currency': 'PLN'}) 110 | -------------------------------------------------------------------------------- /djmoney/tests/form_tests.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on May 7, 2011 3 | 4 | @author: jake 5 | ''' 6 | from decimal import Decimal 7 | from warnings import warn 8 | 9 | import moneyed 10 | from django.test import TestCase 11 | from moneyed import Money 12 | 13 | from .testapp.forms import MoneyForm, OptionalMoneyForm, MoneyModelForm, MoneyFormMultipleCurrencies 14 | from .testapp.models import ModelWithVanillaMoneyField 15 | 16 | 17 | class MoneyFormTestCase(TestCase): 18 | def testRender(self): 19 | warn('Rendering depends on localization.', DeprecationWarning) 20 | 21 | def testValidate(self): 22 | m = Money(Decimal(10), moneyed.SEK) 23 | 24 | form = MoneyForm({"money_0": m.amount, "money_1": m.currency}) 25 | 26 | self.assertTrue(form.is_valid()) 27 | 28 | result = form.cleaned_data['money'] 29 | self.assertTrue(isinstance(result, Money)) 30 | 31 | self.assertEquals(result.amount, Decimal("10")) 32 | self.assertEquals(result.currency, moneyed.SEK) 33 | self.assertEquals(result, m) 34 | 35 | def testAmountIsNotANumber(self): 36 | form = MoneyForm({"money_0": "xyz*|\\", "money_1": moneyed.SEK}) 37 | self.assertFalse(form.is_valid()) 38 | 39 | def testAmountExceedsMaxValue(self): 40 | form = MoneyForm({"money_0": 10000, "money_1": moneyed.SEK}) 41 | self.assertFalse(form.is_valid()) 42 | 43 | def testAmountExceedsMinValue(self): 44 | form = MoneyForm({"money_0": 1, "money_1": moneyed.SEK}) 45 | self.assertFalse(form.is_valid()) 46 | 47 | def testNonExistentCurrency(self): 48 | m = Money(Decimal(10), moneyed.EUR) 49 | form = MoneyForm({"money_0": m.amount, "money_1": m.currency}) 50 | self.assertFalse(form.is_valid()) 51 | 52 | def testChangedData(self): 53 | # Form displays first currency pre-selected, and we don't 54 | # want that to count as changed data. 55 | form = MoneyForm({"money_0": "", "money_1": moneyed.SEK}) 56 | self.assertEquals(form.changed_data, []) 57 | 58 | # But if user types something it, it should be noticed: 59 | form2 = MoneyForm({"money_0": "1.23", "money_1": moneyed.SEK}) 60 | self.assertEquals(form2.changed_data, ['money']) 61 | 62 | 63 | class MoneyFormMultipleCurrenciesTestCase(TestCase): 64 | 65 | def testChangeCurrencyNotAmount(self): 66 | # If the amount is the same, but the currency changes, then we 67 | # should consider this to be a change. 68 | initial_money = Money(Decimal(10), moneyed.SEK) 69 | new_money = Money(Decimal(10), moneyed.EUR) 70 | 71 | initial = {'money': initial_money} 72 | data = {'money_0': new_money.amount, 'money_1': new_money.currency} 73 | 74 | form = MoneyFormMultipleCurrencies(data, initial=initial) 75 | self.assertEquals(form.changed_data, ['money']) 76 | 77 | 78 | class OptionalMoneyFormTestCase(TestCase): 79 | 80 | # The currency widget means that 'money_1' will always be filled 81 | # in, but 'money_0' could be absent/empty. 82 | def testMissingAmount(self): 83 | form = OptionalMoneyForm({"money_1": moneyed.SEK}) 84 | self.assertTrue(form.is_valid()) 85 | 86 | def testEmptyAmount(self): 87 | form = OptionalMoneyForm({"money_0": "", "money_1": moneyed.SEK}) 88 | self.assertTrue(form.is_valid()) 89 | 90 | def testAmountIsNotANumber(self): 91 | # Should still complain for invalid data 92 | form = OptionalMoneyForm({"money_0": "xyz*|\\", "money_1": moneyed.SEK}) 93 | self.assertFalse(form.is_valid()) 94 | 95 | 96 | class MoneyModelFormTestCase(TestCase): 97 | def testSave(self): 98 | m = Money(Decimal("10"), moneyed.SEK) 99 | form = MoneyModelForm({"money_0": m.amount, "money_1": m.currency}) 100 | 101 | self.assertTrue(form.is_valid()) 102 | model = form.save() 103 | 104 | retrieved = ModelWithVanillaMoneyField.objects.get(pk=model.pk) 105 | self.assertEqual(m, retrieved.money) 106 | -------------------------------------------------------------------------------- /djmoney/templatetags/djmoney.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template import TemplateSyntaxError 3 | from moneyed import Money 4 | 5 | from ..models.fields import MoneyPatched 6 | 7 | register = template.Library() 8 | 9 | 10 | class MoneyLocalizeNode(template.Node): 11 | 12 | def __repr__(self): 13 | return "" % self.money 14 | 15 | def __init__(self, money=None, amount=None, currency=None, use_l10n=None, 16 | var_name=None): 17 | 18 | if money and (amount or currency): 19 | raise Exception('You can define either "money" or the' 20 | ' "amount" and "currency".') 21 | 22 | self.money = money 23 | self.amount = amount 24 | self.currency = currency 25 | self.use_l10n = use_l10n 26 | self.var_name = var_name 27 | 28 | @classmethod 29 | def handle_token(cls, parser, token): 30 | 31 | tokens = token.contents.split() 32 | 33 | # default value 34 | var_name = None 35 | use_l10n = True 36 | 37 | # GET variable var_name 38 | if len(tokens) > 3: 39 | if tokens[-2] == 'as': 40 | var_name = parser.compile_filter(tokens[-1]) 41 | # remove the already used data 42 | tokens = tokens[0:-2] 43 | 44 | # GET variable use_l10n 45 | if tokens[-1].lower() in ('on', 'off'): 46 | 47 | if tokens[-1].lower() == 'on': 48 | use_l10n = True 49 | else: 50 | use_l10n = False 51 | # remove the already used data 52 | tokens.pop(-1) 53 | 54 | # GET variable money 55 | if len(tokens) == 2: 56 | return cls(money=parser.compile_filter(tokens[1]), 57 | var_name=var_name, use_l10n=use_l10n) 58 | 59 | # GET variable amount and currency 60 | if len(tokens) == 3: 61 | return cls(amount=parser.compile_filter(tokens[1]), 62 | currency=parser.compile_filter(tokens[2]), 63 | var_name=var_name, use_l10n=use_l10n) 64 | 65 | raise TemplateSyntaxError('Wrong number of input data to the tag.') 66 | 67 | def render(self, context): 68 | 69 | money = self.money.resolve(context) if self.money else None 70 | amount = self.amount.resolve(context) if self.amount else None 71 | currency = self.currency.resolve(context) if self.currency else None 72 | 73 | if money is not None: 74 | if isinstance(money, Money): 75 | money = MoneyPatched._patch_to_current_class(money) 76 | else: 77 | raise TemplateSyntaxError('The variable "money" must be an ' 78 | 'instance of Money.') 79 | 80 | elif amount is not None and currency is not None: 81 | money = MoneyPatched(float(amount), str(currency)) 82 | else: 83 | raise TemplateSyntaxError('You must define both variables: ' 84 | 'amount and currency.') 85 | 86 | money.use_l10n = self.use_l10n 87 | 88 | if self.var_name is None: 89 | return money 90 | 91 | # as 92 | context[self.var_name.token] = money 93 | return '' 94 | 95 | 96 | @register.tag 97 | def money_localize(parser, token): 98 | """ 99 | Usage:: 100 | 101 | {% money_localize [ on(default) | off ] [as var_name] %} 102 | {% money_localize [ on(default) | off ] [as var_name] %} 103 | 104 | Example: 105 | 106 | The same effect: 107 | {% money_localize money_object %} 108 | {% money_localize money_object on %} 109 | 110 | Assignment to a variable: 111 | {% money_localize money_object on as NEW_MONEY_OBJECT %} 112 | 113 | Formatting the number with currency: 114 | {% money_localize '4.5' 'USD' %} 115 | 116 | Return:: 117 | 118 | MoneyPatched object 119 | 120 | """ 121 | return MoneyLocalizeNode.handle_token(parser, token) 122 | -------------------------------------------------------------------------------- /gen_tox.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ############################################################################ 3 | # 4 | # Generator configuration for the "tox" 5 | # 6 | # ############################################################################ 7 | 8 | # : 9 | PYTHON_DICT=( "py26:python2.6" 10 | "py27:python2.7" 11 | "py32:python3.2" 12 | "py33:python3.3" 13 | "pypy:pypy" ) 14 | 15 | # : 16 | DJANGO_DICT=( "django14:1.4.x" 17 | "django15:1.5.x" 18 | "django16:1.6.x" 19 | "djangolatest:latest" ) 20 | 21 | # : 22 | PYMONEYED_DICT=( "pm04:0.4" 23 | "pm05:0.5" 24 | "pmlatest:latest" ) 25 | 26 | # condition[ ...]: skip[ ...] 27 | TOX_SKIP_CONDITIONS=( "pm04: py32 py33" 28 | "py33 py32: django14") 29 | 30 | function test_conditions() { 31 | for condition_item in "${TOX_SKIP_CONDITIONS[@]}" 32 | do 33 | for condition in ${condition_item%%:*} 34 | do 35 | for skip in ${condition_item##*:} 36 | do 37 | for var in "$@" 38 | do 39 | 40 | if [ "$condition" == "$var" ] 41 | then 42 | for vars in "$@" 43 | do 44 | if [ "$skip" == "$vars" ] 45 | then 46 | echo "Condition '$condition' skip '$skip'" 47 | return 48 | fi 49 | done 50 | fi 51 | done 52 | done 53 | done 54 | done 55 | } 56 | 57 | function get_tox() { 58 | 59 | cat <=2.3.0 103 | south>=0.8.2 104 | django-reversion 105 | 106 | [django] 107 | 1.4.x = Django>=1.4,<1.5 108 | 1.5.x = Django>=1.5,<1.6 109 | 1.6.x = Django>=1.6,<1.7 110 | latest = https://github.com/django/django/tarball/master 111 | 112 | [pymoneyed] 113 | 0.4 = py-moneyed>=0.4,<0.5 114 | 0.5 = py-moneyed>=0.5,<0.6 115 | latest = https://github.com/limist/py-moneyed/archive/master.zip 116 | 117 | EOF 118 | } 119 | 120 | function gen_testenv() { 121 | PYTHON_ID=$1 122 | PYTHON_VER=$2 123 | 124 | DJANGO_ID=$3 125 | DJANGO_VER=$4 126 | 127 | PYMONEYED_ID=$5 128 | PYMONEYED_VER=$6 129 | 130 | cat < [ on(default) | off ] [as var_name] %} 194 | {% money_localize [ on(default) | off ] [as var_name] %} 195 | 196 | Examples: 197 | 198 | The same effect: 199 | 200 | :: 201 | 202 | {% money_localize money_object %} 203 | {% money_localize money_object on %} 204 | 205 | Assignment to a variable: 206 | 207 | :: 208 | 209 | {% money_localize money_object on as NEW_MONEY_OBJECT %} 210 | 211 | Formatting the number with currency: 212 | 213 | :: 214 | 215 | {% money_localize '4.5' 'USD' %} 216 | 217 | :: 218 | 219 | Return:: 220 | 221 | MoneyPatched object 222 | 223 | Testing 224 | ------- 225 | 226 | Install the required packages: 227 | 228 | :: 229 | 230 | git clone https://github.com/django-money/django-money 231 | 232 | cd ./django-money/ 233 | 234 | pip install -e .[tests] # installation with required packages for testing 235 | 236 | Recommended way to run the tests: 237 | 238 | :: 239 | 240 | tox 241 | 242 | or 243 | 244 | :: 245 | 246 | python setup.py test 247 | 248 | Testing the application in the current environment python: 249 | 250 | - the main tests 251 | 252 | ./runtests.py 253 | 254 | A handful of the tox environments are automatically tested on travis: 255 | see ``gen_travis.bash`` and ``.travis.yml``. 256 | 257 | Working with Exchange Rates 258 | --------------------------- 259 | 260 | To work with exchange rates, check out this repo that builds off of 261 | django-money: https://github.com/evonove/django-money-rates 262 | 263 | -------------------------------------------------------------------------------- /djmoney/models/managers.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | import django 4 | try: 5 | from django.db.models.expressions import BaseExpression, F 6 | except ImportError: 7 | # Django < 1.8 8 | from django.db.models.expressions import ExpressionNode as BaseExpression, F 9 | from django.db.models.sql.query import Query 10 | from djmoney.models.fields import MoneyField 11 | from django.db.models.query_utils import Q 12 | from moneyed import Money 13 | 14 | 15 | try: 16 | from django.utils.encoding import smart_unicode 17 | except ImportError: 18 | # Python 3 19 | from django.utils.encoding import smart_text as smart_unicode 20 | 21 | from djmoney.utils import get_currency_field_name 22 | 23 | try: 24 | from django.db.models.constants import LOOKUP_SEP 25 | except ImportError: 26 | # Django < 1.5 27 | LOOKUP_SEP = '__' 28 | 29 | from django.db.models.sql.constants import QUERY_TERMS 30 | 31 | 32 | def _get_clean_name(name): 33 | 34 | # Get rid of __lt, __gt etc for the currency lookup 35 | path = name.split(LOOKUP_SEP) 36 | if path[-1] in QUERY_TERMS: 37 | return LOOKUP_SEP.join(path[:-1]) 38 | else: 39 | return name 40 | 41 | 42 | def _get_field(model, name): 43 | if django.VERSION[0] >= 1 and django.VERSION[1] >= 8: 44 | # Django 1.8+ - can use something like 45 | # expression.output_field.get_internal_field() == 'Money..' 46 | raise NotImplementedError("Django 1.8+ support is not implemented.") 47 | 48 | from django.db.models.fields import FieldDoesNotExist 49 | 50 | # Create a fake query object so we can easily work out what field 51 | # type we are dealing with 52 | qs = Query(model) 53 | opts = qs.get_meta() 54 | alias = qs.get_initial_alias() 55 | 56 | parts = name.split(LOOKUP_SEP) 57 | 58 | # The following is borrowed from the innards of Query.add_filter - it strips out __gt, __exact et al. 59 | num_parts = len(parts) 60 | if num_parts > 1 and parts[-1] in qs.query_terms: 61 | # Traverse the lookup query to distinguish related fields from 62 | # lookup types. 63 | lookup_model = model 64 | for counter, field_name in enumerate(parts): 65 | try: 66 | lookup_field = lookup_model._meta.get_field(field_name) 67 | except FieldDoesNotExist: 68 | # Not a field. Bail out. 69 | parts.pop() 70 | break 71 | # Unless we're at the end of the list of lookups, let's attempt 72 | # to continue traversing relations. 73 | if (counter + 1) < num_parts: 74 | try: 75 | lookup_model = lookup_field.rel.to 76 | except AttributeError: 77 | # Not a related field. Bail out. 78 | parts.pop() 79 | break 80 | 81 | if django.VERSION[0] >= 1 and django.VERSION[1] in (6, 7): 82 | # Django 1.6-1.7 83 | field = qs.setup_joins(parts, opts, alias)[0] 84 | else: 85 | # Django 1.4-1.5 86 | field = qs.setup_joins(parts, opts, alias, False)[0] 87 | 88 | return field 89 | 90 | 91 | def _expand_money_args(model, args): 92 | """ 93 | Augments args so that they contain _currency lookups - ie.. Q() | Q() 94 | """ 95 | for arg in args: 96 | if isinstance(arg, Q): 97 | for i, child in enumerate(arg.children): 98 | if isinstance(child, Q): 99 | _expand_money_args(model, [child]) 100 | elif isinstance(child, (list, tuple)): 101 | name, value = child 102 | if isinstance(value, Money): 103 | clean_name = _get_clean_name(name) 104 | arg.children[i] = Q(*[ 105 | child, 106 | (get_currency_field_name(clean_name), smart_unicode(value.currency)) 107 | ]) 108 | if isinstance(value, BaseExpression): 109 | field = _get_field(model, name) 110 | if isinstance(field, MoneyField): 111 | clean_name = _get_clean_name(name) 112 | arg.children[i] = Q(*[ 113 | child, 114 | ('_'.join([clean_name, 'currency']), F(get_currency_field_name(value.name))) 115 | ]) 116 | return args 117 | 118 | 119 | def _expand_money_kwargs(model, kwargs): 120 | """ 121 | Augments kwargs so that they contain _currency lookups. 122 | """ 123 | to_append = {} 124 | for name, value in kwargs.items(): 125 | if isinstance(value, Money): 126 | clean_name = _get_clean_name(name) 127 | to_append[name] = value.amount 128 | to_append[get_currency_field_name(clean_name)] = smart_unicode( 129 | value.currency) 130 | if isinstance(value, BaseExpression): 131 | field = _get_field(model, name) 132 | if isinstance(field, MoneyField): 133 | clean_name = _get_clean_name(name) 134 | to_append['_'.join([clean_name, 'currency'])] = F(get_currency_field_name(value.name)) 135 | 136 | kwargs.update(to_append) 137 | return kwargs 138 | 139 | 140 | def understands_money(model, func): 141 | """ 142 | Used to wrap a queryset method with logic to expand 143 | a query from something like: 144 | 145 | mymodel.objects.filter(money=Money(100,"USD")) 146 | 147 | To something equivalent to: 148 | 149 | mymodel.objects.filter(money=Decimal("100.0), money_currency="USD") 150 | """ 151 | 152 | @wraps(func) 153 | def wrapper(*args, **kwargs): 154 | args = _expand_money_args(model, args) 155 | kwargs = kwargs.copy() 156 | kwargs = _expand_money_kwargs(model, kwargs) 157 | return func(*args, **kwargs) 158 | 159 | return wrapper 160 | 161 | 162 | RELEVANT_QUERYSET_METHODS = ['distinct', 'get', 'get_or_create', 'filter', 163 | 'exclude'] 164 | 165 | 166 | def add_money_comprehension_to_queryset(model, qs): 167 | # Decorate each relevant method with understand_money in the queryset given 168 | for attr in RELEVANT_QUERYSET_METHODS: 169 | setattr(qs, attr, understands_money(model, getattr(qs, attr))) 170 | return qs 171 | 172 | 173 | def money_manager(manager): 174 | """ 175 | Patches a model manager's get_queryset method so that each QuerySet it returns 176 | is able to work on money fields. 177 | 178 | This allow users of django-money to use other managers while still doing 179 | money queries. 180 | """ 181 | 182 | # Need to dynamically subclass to add our behaviour, and then change 183 | # the class of 'manager' to our subclass. 184 | 185 | # Rejected alternatives: 186 | # 187 | # * A monkey patch that adds things to the manager instance dictionary. 188 | # This fails due to complications with Manager._copy_to_model behaviour. 189 | # 190 | # * Returning a new MoneyManager instance (rather than modifying 191 | # the passed in manager instance). This fails for reasons that 192 | # are tricky to get to the bottom of - Manager does funny things. 193 | class MoneyManager(manager.__class__): 194 | 195 | def get_queryset(self, *args, **kwargs): 196 | # If we are calling code that is pre-Django 1.6, need to 197 | # spell it 'get_query_set' 198 | s = super(MoneyManager, self) 199 | method = getattr(s, 'get_queryset', 200 | getattr(s, 'get_query_set', None)) 201 | return add_money_comprehension_to_queryset(self.model, method(*args, **kwargs)) 202 | 203 | # If we are being called by code pre Django 1.6, need 204 | # 'get_query_set'. 205 | if django.VERSION < (1, 6): 206 | get_query_set = get_queryset 207 | 208 | manager.__class__ = MoneyManager 209 | return manager 210 | -------------------------------------------------------------------------------- /djmoney/tests/model_tests.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on May 7, 2011 3 | 4 | @author: jake 5 | ''' 6 | from decimal import Decimal 7 | from django.test import TestCase 8 | from django.db.models import F, Q 9 | try: 10 | from unittest import skipIf 11 | except ImportError: 12 | # For python2.6 compatibility 13 | from unittest2 import skipIf 14 | from moneyed import Money 15 | from .testapp.models import (ModelWithVanillaMoneyField, 16 | ModelRelatedToModelWithMoney, ModelWithChoicesMoneyField, BaseModel, InheritedModel, InheritorModel, 17 | SimpleModel, NullMoneyFieldModel, ModelWithDefaultAsDecimal, ModelWithDefaultAsFloat, ModelWithDefaultAsInt, 18 | ModelWithDefaultAsString, ModelWithDefaultAsStringWithCurrency, ModelWithDefaultAsMoney, ModelWithTwoMoneyFields, 19 | ProxyModel, ModelWithNonMoneyField) 20 | from djmoney.models.fields import MoneyPatched, AUTO_CONVERT_MONEY 21 | import moneyed 22 | from mock import patch 23 | 24 | 25 | class VanillaMoneyFieldTestCase(TestCase): 26 | 27 | def testSaving(self): 28 | 29 | somemoney = Money("100.0") 30 | 31 | model = ModelWithVanillaMoneyField(money=somemoney) 32 | model.save() 33 | 34 | retrieved = ModelWithVanillaMoneyField.objects.get(pk=model.pk) 35 | 36 | self.assertEquals(somemoney.currency, retrieved.money.currency) 37 | self.assertEquals(somemoney, retrieved.money) 38 | 39 | # Try setting the value directly 40 | retrieved.money = Money(1, moneyed.DKK) 41 | retrieved.save() 42 | retrieved = ModelWithVanillaMoneyField.objects.get(pk=model.pk) 43 | 44 | self.assertEquals(Money(1, moneyed.DKK), retrieved.money) 45 | 46 | object = BaseModel.objects.create() 47 | self.assertEquals(Money(0, 'USD'), object.first_field) 48 | object = BaseModel.objects.create(first_field='111.2') 49 | self.assertEquals(Money('111.2', 'USD'), object.first_field) 50 | object = BaseModel.objects.create(first_field=Money('123', 'PLN')) 51 | self.assertEquals(Money('123', 'PLN'), object.first_field) 52 | 53 | object = ModelWithDefaultAsDecimal.objects.create() 54 | self.assertEquals(Money('0.01', 'CHF'), object.money) 55 | object = ModelWithDefaultAsInt.objects.create() 56 | self.assertEquals(Money('123', 'GHS'), object.money) 57 | object = ModelWithDefaultAsString.objects.create() 58 | self.assertEquals(Money('123', 'PLN'), object.money) 59 | object = ModelWithDefaultAsStringWithCurrency.objects.create() 60 | self.assertEquals(Money('123', 'USD'), object.money) 61 | object = ModelWithDefaultAsFloat.objects.create() 62 | self.assertEquals(Money('12.05', 'PLN'), object.money) 63 | object = ModelWithDefaultAsMoney.objects.create() 64 | self.assertEquals(Money('0.01', 'RUB'), object.money) 65 | 66 | def testRounding(self): 67 | somemoney = Money("100.0623456781123219") 68 | 69 | model = ModelWithVanillaMoneyField(money=somemoney) 70 | model.save() 71 | 72 | retrieved = ModelWithVanillaMoneyField.objects.get(pk=model.pk) 73 | 74 | self.assertEquals(somemoney.currency, retrieved.money.currency) 75 | self.assertEquals(Money("100.06"), retrieved.money) 76 | 77 | def testRelativeAddition(self): 78 | # test relative value adding 79 | somemoney = Money(100, 'USD') 80 | mymodel = ModelWithVanillaMoneyField.objects.create(money=somemoney) 81 | # duplicate money 82 | mymodel.money = F('money') + somemoney 83 | mymodel.save() 84 | mymodel = ModelWithVanillaMoneyField.objects.get(pk=mymodel.pk) 85 | self.assertEquals(mymodel.money, 2 * somemoney) 86 | # subtract everything. 87 | mymodel.money = F('money') - (2 * somemoney) 88 | mymodel.save() 89 | mymodel = ModelWithVanillaMoneyField.objects.get(pk=mymodel.pk) 90 | self.assertEquals(Money(0, 'USD'), mymodel.money) 91 | 92 | def testComparisonLookup(self): 93 | ModelWithTwoMoneyFields.objects.create(amount1=Money(1, 'USD'), amount2=Money(2, 'USD')) 94 | ModelWithTwoMoneyFields.objects.create(amount1=Money(2, 'USD'), amount2=Money(0, 'USD')) 95 | ModelWithTwoMoneyFields.objects.create(amount1=Money(3, 'USD'), amount2=Money(0, 'USD')) 96 | ModelWithTwoMoneyFields.objects.create(amount1=Money(4, 'USD'), amount2=Money(0, 'GHS')) 97 | ModelWithTwoMoneyFields.objects.create(amount1=Money(5, 'USD'), amount2=Money(5, 'USD')) 98 | 99 | qs = ModelWithTwoMoneyFields.objects.filter(amount1=F('amount2')) 100 | self.assertEquals(1, qs.count()) 101 | 102 | qs = ModelWithTwoMoneyFields.objects.filter(amount1__gt=F('amount2')) 103 | self.assertEquals(2, qs.count()) 104 | 105 | qs = ModelWithTwoMoneyFields.objects.filter(Q(amount1=Money(1, 'USD')) | Q(amount2=Money(0, 'USD'))) 106 | self.assertEquals(3, qs.count()) 107 | 108 | qs = ModelWithTwoMoneyFields.objects.filter(Q(amount1=Money(1, 'USD')) | Q(amount1=Money(4, 'USD')) | Q(amount2=Money(0, 'GHS'))) 109 | self.assertEquals(2, qs.count()) 110 | 111 | qs = ModelWithTwoMoneyFields.objects.filter(Q(amount1=Money(1, 'USD')) | Q(amount1=Money(5, 'USD')) | Q(amount2=Money(0, 'GHS'))) 112 | self.assertEquals(3, qs.count()) 113 | 114 | qs = ModelWithTwoMoneyFields.objects.filter(Q(amount1=Money(1, 'USD')) | Q(amount1=Money(4, 'USD'), amount2=Money(0, 'GHS'))) 115 | self.assertEquals(2, qs.count()) 116 | 117 | qs = ModelWithTwoMoneyFields.objects.filter(Q(amount1=Money(1, 'USD')) | Q(amount1__gt=Money(4, 'USD'), amount2=Money(0, 'GHS'))) 118 | self.assertEquals(1, qs.count()) 119 | 120 | qs = ModelWithTwoMoneyFields.objects.filter(Q(amount1=Money(1, 'USD')) | Q(amount1__gte=Money(4, 'USD'), amount2=Money(0, 'GHS'))) 121 | self.assertEquals(2, qs.count()) 122 | 123 | def testExactMatch(self): 124 | 125 | somemoney = Money("100.0") 126 | 127 | model = ModelWithVanillaMoneyField() 128 | model.money = somemoney 129 | 130 | model.save() 131 | 132 | retrieved = ModelWithVanillaMoneyField.objects.get(money=somemoney) 133 | 134 | self.assertEquals(model.pk, retrieved.pk) 135 | 136 | def testRangeSearch(self): 137 | 138 | minMoney = Money("3") 139 | 140 | model = ModelWithVanillaMoneyField(money=Money("100.0")) 141 | 142 | model.save() 143 | 144 | retrieved = ModelWithVanillaMoneyField.objects.get(money__gt=minMoney) 145 | self.assertEquals(model.pk, retrieved.pk) 146 | 147 | shouldBeEmpty = ModelWithVanillaMoneyField.objects.filter(money__lt=minMoney) 148 | self.assertEquals(shouldBeEmpty.count(), 0) 149 | 150 | def testCurrencySearch(self): 151 | 152 | otherMoney = Money("1000", moneyed.USD) 153 | correctMoney = Money("1000", moneyed.ZWN) 154 | 155 | model = ModelWithVanillaMoneyField(money=Money("100.0", moneyed.ZWN)) 156 | model.save() 157 | 158 | shouldBeEmpty = ModelWithVanillaMoneyField.objects.filter(money__lt=otherMoney) 159 | self.assertEquals(shouldBeEmpty.count(), 0) 160 | 161 | shouldBeOne = ModelWithVanillaMoneyField.objects.filter(money__lt=correctMoney) 162 | self.assertEquals(shouldBeOne.count(), 1) 163 | 164 | def testCurrencyChoices(self): 165 | 166 | otherMoney = Money("1000", moneyed.USD) 167 | correctMoney = Money("1000", moneyed.ZWN) 168 | 169 | model = ModelWithChoicesMoneyField( 170 | money=Money("100.0", moneyed.ZWN) 171 | ) 172 | model.save() 173 | 174 | shouldBeEmpty = ModelWithChoicesMoneyField.objects.filter(money__lt=otherMoney) 175 | self.assertEquals(shouldBeEmpty.count(), 0) 176 | 177 | shouldBeOne = ModelWithChoicesMoneyField.objects.filter(money__lt=correctMoney) 178 | self.assertEquals(shouldBeOne.count(), 1) 179 | 180 | model = ModelWithChoicesMoneyField( 181 | money=Money("100.0", moneyed.USD) 182 | ) 183 | model.save() 184 | 185 | def testIsNullLookup(self): 186 | 187 | null_instance = NullMoneyFieldModel.objects.create(field=None) 188 | null_instance.save() 189 | 190 | normal_instance = NullMoneyFieldModel.objects.create(field=Money(100, 'USD')) 191 | normal_instance.save() 192 | 193 | shouldBeOne = NullMoneyFieldModel.objects.filter(field=None) 194 | self.assertEquals(shouldBeOne.count(), 1) 195 | 196 | def testNullDefault(self): 197 | null_instance = NullMoneyFieldModel.objects.create() 198 | self.assertEquals(null_instance.field, None) 199 | 200 | 201 | class RelatedModelsTestCase(TestCase): 202 | 203 | def testFindModelsRelatedToMoneyModels(self): 204 | 205 | moneyModel = ModelWithVanillaMoneyField(money=Money("100.0", moneyed.ZWN)) 206 | moneyModel.save() 207 | 208 | relatedModel = ModelRelatedToModelWithMoney(moneyModel=moneyModel) 209 | relatedModel.save() 210 | 211 | ModelRelatedToModelWithMoney.objects.get(moneyModel__money=Money("100.0", moneyed.ZWN)) 212 | ModelRelatedToModelWithMoney.objects.get(moneyModel__money__lt=Money("1000.0", moneyed.ZWN)) 213 | 214 | 215 | class NonMoneyTestCase(TestCase): 216 | 217 | def testAllowExpressionNodesWithoutMoney(self): 218 | """ allow querying on expression nodes that are not Money """ 219 | ModelWithNonMoneyField(money=Money(100.0), desc="hundred").save() 220 | instance = ModelWithNonMoneyField.objects.filter(desc=F("desc")).get() 221 | self.assertEqual(instance.desc, "hundred") 222 | 223 | 224 | class InheritedModelTestCase(TestCase): 225 | """Test inheritence from a concrete model""" 226 | 227 | def testBaseModel(self): 228 | self.assertEqual(BaseModel.objects.model, BaseModel) 229 | 230 | def testInheritedModel(self): 231 | self.assertEqual(InheritedModel.objects.model, InheritedModel) 232 | moneyModel = InheritedModel( 233 | first_field=Money("100.0", moneyed.ZWN), 234 | second_field=Money("200.0", moneyed.USD), 235 | ) 236 | moneyModel.save() 237 | self.assertEqual(moneyModel.first_field, Money(100.0, moneyed.ZWN)) 238 | self.assertEqual(moneyModel.second_field, Money(200.0, moneyed.USD)) 239 | 240 | 241 | class InheritorModelTestCase(TestCase): 242 | """Test inheritence from an ABSTRACT model""" 243 | 244 | def testInheritorModel(self): 245 | self.assertEqual(InheritorModel.objects.model, InheritorModel) 246 | moneyModel = InheritorModel( 247 | price1=Money("100.0", moneyed.ZWN), 248 | price2=Money("200.0", moneyed.USD), 249 | ) 250 | moneyModel.save() 251 | self.assertEqual(moneyModel.price1, Money(100.0, moneyed.ZWN)) 252 | self.assertEqual(moneyModel.price2, Money(200.0, moneyed.USD)) 253 | 254 | 255 | class ManagerTest(TestCase): 256 | 257 | def test_manager(self): 258 | self.assertTrue(hasattr(SimpleModel, 'objects')) 259 | 260 | def test_objects_creation(self): 261 | SimpleModel.objects.create(money=Money("100.0", 'USD')) 262 | self.assertEqual(SimpleModel.objects.count(), 1) 263 | 264 | 265 | class ProxyModelTest(TestCase): 266 | 267 | def test_instances(self): 268 | ProxyModel.objects.create(money=Money("100.0", 'USD')) 269 | self.assertIsInstance(ProxyModel.objects.get(pk=1), ProxyModel) 270 | 271 | def test_patching(self): 272 | ProxyModel.objects.create(money=Money("100.0", 'USD')) 273 | # This will fail if ProxyModel.objects doesn't have the patched manager: 274 | self.assertEqual(ProxyModel.objects.filter(money__gt=Money("50.00", 'GBP')).count(), 275 | 0) 276 | 277 | 278 | class DifferentCurrencyTestCase(TestCase): 279 | """Test sum/sub operations between different currencies""" 280 | 281 | @skipIf(AUTO_CONVERT_MONEY is False, "You need to install django-money-rates to run this test") 282 | def test_sum(self): 283 | with patch( 284 | 'djmoney.models.fields.convert_money', 285 | side_effect=lambda amount, cur_from, cur_to: Money((amount * Decimal(0.88)), cur_to) 286 | ): 287 | result = MoneyPatched(10, 'EUR') + Money(1, 'USD') 288 | self.assertEqual(round(result.amount, 2), 10.88) 289 | self.assertEqual(result.currency, moneyed.EUR) 290 | 291 | @skipIf(AUTO_CONVERT_MONEY is False, "You need to install django-money-rates to run this test") 292 | def test_sub(self): 293 | with patch( 294 | 'djmoney.models.fields.convert_money', 295 | side_effect=lambda amount, cur_from, cur_to: Money((amount * Decimal(0.88)), cur_to) 296 | ): 297 | result = MoneyPatched(10, 'EUR') - Money(1, 'USD') 298 | self.assertEqual(round(result.amount, 2), 9.23) 299 | self.assertEqual(result.currency, moneyed.EUR) 300 | 301 | def test_eq(self): 302 | self.assertEqual(MoneyPatched(1, 'EUR'), Money(1, 'EUR')) 303 | self.assertNotEqual(MoneyPatched(1, 'EUR'), Money(2, 'EUR')) 304 | with self.assertRaises(TypeError): 305 | MoneyPatched(10, 'EUR') == Money(10, 'USD') 306 | -------------------------------------------------------------------------------- /djmoney/models/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from django.db import models 3 | from django.conf import settings 4 | try: 5 | from django.db.models.expressions import Expression 6 | except ImportError: 7 | # Django < 1.8 8 | from django.db.models.sql.expressions import SQLEvaluator as Expression 9 | try: 10 | from django.utils.encoding import smart_unicode 11 | except ImportError: 12 | # Python 3 13 | from django.utils.encoding import smart_text as smart_unicode 14 | from django.utils import translation 15 | from django.db.models.signals import class_prepared 16 | from moneyed import Money, Currency 17 | from moneyed.localization import _FORMATTER, format_money 18 | from djmoney import forms 19 | from djmoney.utils import get_currency_field_name 20 | try: 21 | from django.db.models.expressions import BaseExpression 22 | except ImportError: 23 | # Django < 1.8 24 | from django.db.models.expressions import ExpressionNode as BaseExpression 25 | 26 | from djmoney.settings import DEFAULT_CURRENCY, CURRENCY_CHOICES 27 | 28 | # If django-money-rates is installed we can automatically 29 | # perform operations with different currencies 30 | try: 31 | from djmoney_rates.utils import convert_money 32 | AUTO_CONVERT_MONEY = True 33 | except ImportError: 34 | AUTO_CONVERT_MONEY = False 35 | 36 | from decimal import Decimal, ROUND_DOWN 37 | import inspect 38 | 39 | try: 40 | unicode = unicode 41 | except NameError: 42 | # 'unicode' is undefined, in Python 3 43 | basestring = (str, bytes) 44 | 45 | __all__ = ('MoneyField', 'NotSupportedLookup') 46 | 47 | SUPPORTED_LOOKUPS = ('exact', 'isnull', 'lt', 'gt', 'lte', 'gte') 48 | 49 | 50 | class NotSupportedLookup(Exception): 51 | def __init__(self, lookup): 52 | self.lookup = lookup 53 | 54 | def __str__(self): 55 | return "Lookup '%s' is not supported for MoneyField" % self.lookup 56 | 57 | 58 | class MoneyPatched(Money): 59 | 60 | # Set to True or False has a higher priority 61 | # than USE_L10N == True in the django settings file. 62 | # The variable "self.use_l10n" has three states: 63 | use_l10n = None 64 | 65 | def __float__(self): 66 | return float(self.amount) 67 | 68 | def _convert_to_local_currency(self, other): 69 | """ 70 | Converts other Money instances to the local currency 71 | """ 72 | if AUTO_CONVERT_MONEY: 73 | return convert_money(other.amount, other.currency, self.currency) 74 | else: 75 | return other 76 | 77 | @classmethod 78 | def _patch_to_current_class(cls, money): 79 | """ 80 | Converts object of type MoneyPatched on the object of type Money. 81 | """ 82 | return cls(money.amount, money.currency) 83 | 84 | def __pos__(self): 85 | return MoneyPatched._patch_to_current_class( 86 | super(MoneyPatched, self).__pos__()) 87 | 88 | def __neg__(self): 89 | return MoneyPatched._patch_to_current_class( 90 | super(MoneyPatched, self).__neg__()) 91 | 92 | def __add__(self, other): 93 | other = self._convert_to_local_currency(other) 94 | return MoneyPatched._patch_to_current_class( 95 | super(MoneyPatched, self).__add__(other)) 96 | 97 | def __sub__(self, other): 98 | other = self._convert_to_local_currency(other) 99 | return MoneyPatched._patch_to_current_class( 100 | super(MoneyPatched, self).__sub__(other)) 101 | 102 | def __mul__(self, other): 103 | 104 | return MoneyPatched._patch_to_current_class( 105 | super(MoneyPatched, self).__mul__(other)) 106 | 107 | def __eq__(self, other): 108 | if hasattr(other, 'currency'): 109 | if self.currency == other.currency: 110 | return self.amount == other.amount 111 | raise TypeError('Cannot add or subtract two Money ' + 112 | 'instances with different currencies.') 113 | return False 114 | 115 | def __truediv__(self, other): 116 | 117 | if isinstance(other, Money): 118 | return super(MoneyPatched, self).__truediv__(other) 119 | else: 120 | return self._patch_to_current_class( 121 | super(MoneyPatched, self).__truediv__(other)) 122 | 123 | def __rmod__(self, other): 124 | 125 | return MoneyPatched._patch_to_current_class( 126 | super(MoneyPatched, self).__rmod__(other)) 127 | 128 | def __get_current_locale(self): 129 | # get_language can return None starting on django 1.8 130 | language = translation.get_language() or settings.LANGUAGE_CODE 131 | locale = translation.to_locale(language) 132 | 133 | if _FORMATTER.get_formatting_definition(locale): 134 | return locale 135 | 136 | if _FORMATTER.get_formatting_definition('%s_%s' % (locale, locale)): 137 | return '%s_%s' % (locale, locale) 138 | 139 | return '' 140 | 141 | def __use_l10n(self): 142 | """ 143 | Return boolean. 144 | """ 145 | if self.use_l10n is None: 146 | return settings.USE_L10N 147 | return self.use_l10n 148 | 149 | def __unicode__(self): 150 | 151 | if self.__use_l10n(): 152 | locale = self.__get_current_locale() 153 | if locale: 154 | return format_money(self, locale=locale) 155 | 156 | return format_money(self) 157 | 158 | def __str__(self): 159 | 160 | if self.__use_l10n(): 161 | locale = self.__get_current_locale() 162 | if locale: 163 | return format_money(self, locale=locale) 164 | 165 | return format_money(self) 166 | 167 | def __repr__(self): 168 | # small fix for tests 169 | return "%s %s" % (self.amount.to_integral_value(ROUND_DOWN), 170 | self.currency) 171 | 172 | 173 | class MoneyFieldProxy(object): 174 | def __init__(self, field): 175 | self.field = field 176 | self.currency_field_name = get_currency_field_name(self.field.name) 177 | 178 | def _money_from_obj(self, obj): 179 | amount = obj.__dict__[self.field.name] 180 | currency = obj.__dict__[self.currency_field_name] 181 | if amount is None: 182 | return None 183 | return MoneyPatched(amount=amount, currency=currency) 184 | 185 | def __get__(self, obj, type=None): 186 | if obj is None: 187 | raise AttributeError('Can only be accessed via an instance.') 188 | if isinstance(obj.__dict__[self.field.name], BaseExpression): 189 | return obj.__dict__[self.field.name] 190 | if not isinstance(obj.__dict__[self.field.name], Money): 191 | obj.__dict__[self.field.name] = self._money_from_obj(obj) 192 | return obj.__dict__[self.field.name] 193 | 194 | def __set__(self, obj, value): 195 | if isinstance(value, tuple): 196 | value = Money(amount=value[0], currency=value[1]) 197 | if isinstance(value, Money): 198 | obj.__dict__[self.field.name] = value.amount 199 | setattr(obj, self.currency_field_name, 200 | smart_unicode(value.currency)) 201 | elif isinstance(value, BaseExpression): 202 | if isinstance(value.children[1], Money): 203 | value.children[1] = value.children[1].amount 204 | obj.__dict__[self.field.name] = value 205 | else: 206 | if value: 207 | value = str(value) 208 | obj.__dict__[self.field.name] = self.field.to_python(value) 209 | 210 | 211 | class CurrencyField(models.CharField): 212 | description = "A field which stores currency." 213 | 214 | def __init__(self, price_field=None, verbose_name=None, name=None, 215 | default=DEFAULT_CURRENCY, **kwargs): 216 | if isinstance(default, Currency): 217 | default = default.code 218 | kwargs['max_length'] = 3 219 | self.price_field = price_field 220 | self.frozen_by_south = kwargs.pop('frozen_by_south', False) 221 | super(CurrencyField, self).__init__(verbose_name, name, default=default, 222 | **kwargs) 223 | 224 | def get_internal_type(self): 225 | return "CharField" 226 | 227 | def contribute_to_class(self, cls, name): 228 | if not self.frozen_by_south and not name in [f.name for f in cls._meta.fields]: 229 | super(CurrencyField, self).contribute_to_class(cls, name) 230 | 231 | 232 | class MoneyField(models.DecimalField): 233 | description = "A field which stores both the currency and amount of money." 234 | 235 | def __init__(self, verbose_name=None, name=None, 236 | max_digits=None, decimal_places=None, 237 | default=None, 238 | default_currency=DEFAULT_CURRENCY, 239 | currency_choices=CURRENCY_CHOICES, **kwargs): 240 | 241 | nullable = kwargs.get('null', False) 242 | if default is None and not nullable: 243 | # Backwards compatible fix for non-nullable fields 244 | default = 0.0 245 | 246 | if isinstance(default, basestring): 247 | try: 248 | # handle scenario where default is formatted like: 249 | # 'amount currency-code' 250 | amount, currency = default.split(" ") 251 | except ValueError: 252 | # value error would be risen if the default is 253 | # without the currency part, i.e 254 | # 'amount' 255 | amount = default 256 | currency = default_currency 257 | default = Money(float(amount), Currency(code=currency)) 258 | elif isinstance(default, (float, Decimal, int)): 259 | default = Money(default, default_currency) 260 | 261 | if not (nullable and default is None) and not isinstance(default, Money): 262 | raise Exception( 263 | "default value must be an instance of Money, is: %s" % str( 264 | default)) 265 | 266 | # Avoid giving the user hard-to-debug errors if they miss required attributes 267 | if max_digits is None: 268 | raise Exception( 269 | "You have to provide a max_digits attribute to Money fields.") 270 | 271 | if decimal_places is None: 272 | raise Exception( 273 | "You have to provide a decimal_places attribute to Money fields.") 274 | 275 | if not default_currency: 276 | default_currency = default.currency 277 | 278 | self.default_currency = default_currency 279 | self.currency_choices = currency_choices 280 | self.frozen_by_south = kwargs.pop('frozen_by_south', False) 281 | 282 | super(MoneyField, self).__init__(verbose_name, name, max_digits, 283 | decimal_places, default=default, 284 | **kwargs) 285 | 286 | def to_python(self, value): 287 | if isinstance(value, Expression): 288 | return value 289 | if isinstance(value, Money): 290 | value = value.amount 291 | if isinstance(value, tuple): 292 | value = value[0] 293 | return super(MoneyField, self).to_python(value) 294 | 295 | def get_internal_type(self): 296 | return "DecimalField" 297 | 298 | def contribute_to_class(self, cls, name): 299 | 300 | cls._meta.has_money_field = True 301 | 302 | # Don't run on abstract classes 303 | # Removed, see https://github.com/jakewins/django-money/issues/42 304 | # if cls._meta.abstract: 305 | # return 306 | 307 | if not self.frozen_by_south: 308 | c_field_name = get_currency_field_name(name) 309 | # Do not change default=self.default_currency.code, needed 310 | # for south compat. 311 | c_field = CurrencyField( 312 | max_length=3, price_field=self, 313 | default=self.default_currency, editable=False, 314 | choices=self.currency_choices 315 | ) 316 | c_field.creation_counter = self.creation_counter 317 | cls.add_to_class(c_field_name, c_field) 318 | 319 | super(MoneyField, self).contribute_to_class(cls, name) 320 | 321 | setattr(cls, self.name, MoneyFieldProxy(self)) 322 | 323 | def get_db_prep_save(self, value, connection): 324 | if isinstance(value, Expression): 325 | return value 326 | if isinstance(value, Money): 327 | value = value.amount 328 | return super(MoneyField, self).get_db_prep_save(value, connection) 329 | 330 | def get_db_prep_lookup(self, lookup_type, value, connection, 331 | prepared=False): 332 | if not lookup_type in SUPPORTED_LOOKUPS: 333 | raise NotSupportedLookup(lookup_type) 334 | value = self.get_db_prep_save(value, connection) 335 | return super(MoneyField, self).get_db_prep_lookup(lookup_type, value, 336 | connection, prepared) 337 | 338 | def get_default(self): 339 | if isinstance(self.default, Money): 340 | frm = inspect.stack()[1] 341 | mod = inspect.getmodule(frm[0]) 342 | # We need to return the numerical value if this is called by south 343 | if mod.__name__ == "south.db.generic": 344 | return float(self.default.amount) 345 | return self.default 346 | else: 347 | return super(MoneyField, self).get_default() 348 | 349 | def formfield(self, **kwargs): 350 | defaults = {'form_class': forms.MoneyField} 351 | defaults.update(kwargs) 352 | defaults['currency_choices'] = self.currency_choices 353 | return super(MoneyField, self).formfield(**defaults) 354 | 355 | def get_south_default(self): 356 | return '%s' % str(self.default) 357 | 358 | def get_south_default_currency(self): 359 | return '"%s"' % str(self.default_currency.code) 360 | 361 | def value_to_string(self, obj): 362 | value = self._get_val_from_obj(obj) 363 | return self.get_prep_value(value) 364 | 365 | # # South support 366 | def south_field_triple(self): 367 | "Returns a suitable description of this field for South." 368 | # Note: This method gets automatically with schemamigration time. 369 | from south.modelsinspector import introspector 370 | field_class = self.__class__.__module__ + "." + self.__class__.__name__ 371 | args, kwargs = introspector(self) 372 | # We need to 373 | # 1. Delete the default, 'cause it's not automatically supported. 374 | kwargs.pop('default') 375 | # 2. add the default currency, because it's not picked up from the inspector automatically. 376 | kwargs['default_currency'] = "'%s'" % self.default_currency 377 | return field_class, args, kwargs 378 | 379 | # # Django 1.7 migration support 380 | def deconstruct(self): 381 | name, path, args, kwargs = super(MoneyField, self).deconstruct() 382 | 383 | if self.default is not None: 384 | kwargs['default'] = self.default.amount 385 | if self.default_currency != DEFAULT_CURRENCY: 386 | kwargs['default_currency'] = str(self.default_currency) 387 | if self.currency_choices != CURRENCY_CHOICES: 388 | kwargs['currency_choices'] = self.currency_choices 389 | return name, path, args, kwargs 390 | 391 | 392 | try: 393 | from south.modelsinspector import add_introspection_rules 394 | rules = [ 395 | # MoneyField has its own method. 396 | ((CurrencyField,), 397 | [], # No positional args 398 | {'default': ('default', {'default': DEFAULT_CURRENCY.code}), 399 | 'max_length': ('max_length', {'default': 3})}), 400 | ] 401 | 402 | # MoneyField implement the serialization in south_field_triple method 403 | add_introspection_rules(rules, ["^djmoney\.models\.fields\.CurrencyField"]) 404 | except ImportError: 405 | pass 406 | 407 | 408 | def patch_managers(sender, **kwargs): 409 | """ 410 | Patches models managers 411 | """ 412 | from .managers import money_manager 413 | 414 | if hasattr(sender._meta, 'has_money_field'): 415 | for _id, name, manager in sender._meta.concrete_managers: 416 | setattr(sender, name, money_manager(manager)) 417 | 418 | 419 | class_prepared.connect(patch_managers) 420 | --------------------------------------------------------------------------------