# -*- coding: utf-8 -*-
from __future__ import division
import inspect
from decimal import ROUND_DOWN, Decimal
from django import VERSION
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import models
from django.db.models import F
from django.db.models.signals import class_prepared
from django.utils import translation
from moneyed import Currency, Money
from moneyed.localization import _FORMATTER, format_money
from djmoney import forms
from .._compat import (
BaseExpression,
Expression,
Func,
Value,
deconstructible,
setup_managers,
smart_unicode,
split_expression,
string_types,
)
from ..settings import CURRENCY_CHOICES, DEFAULT_CURRENCY
from ..utils import get_currency_field_name, prepare_expression
__all__ = ('MoneyField', 'NotSupportedLookup')
SUPPORTED_LOOKUPS = ('exact', 'isnull', 'in', 'lt', 'gt', 'lte', 'gte')
class NotSupportedLookup(Exception):
def __init__(self, lookup):
super(NotSupportedLookup, self).__init__('Lookup \'%s\' is not supported for MoneyField' % lookup)
@deconstructible
class MoneyPatched(Money):
# Set to True or False has a higher priority
# than USE_L10N == True in the django settings file.
# The variable "self.use_l10n" has three states:
use_l10n = None
def __float__(self):
return float(self.amount)
def _convert_to_local_currency(self, other):
"""
Converts other Money instances to the local currency.
If django-money-rates is installed we can automatically perform operations with different currencies
"""
if getattr(settings, 'AUTO_CONVERT_MONEY', False):
if 'djmoney_rates' in settings.INSTALLED_APPS:
try:
from djmoney_rates.utils import convert_money
return convert_money(other.amount, other.currency, self.currency)
except ImportError:
raise ImproperlyConfigured('djmoney_rates doesn\'t support Django 1.9+')
raise ImproperlyConfigured('You must install djmoney-rates to use AUTO_CONVERT_MONEY = True')
return other
@classmethod
def _patch_to_current_class(cls, money):
"""
Converts object of type MoneyPatched on the object of type Money.
"""
return cls(money.amount, money.currency)
def __pos__(self):
return MoneyPatched._patch_to_current_class(
super(MoneyPatched, self).__pos__())
def __neg__(self):
return MoneyPatched._patch_to_current_class(
super(MoneyPatched, self).__neg__())
def __add__(self, other):
other = self._convert_to_local_currency(other)
return MoneyPatched._patch_to_current_class(
super(MoneyPatched, self).__add__(other))
def __sub__(self, other):
other = self._convert_to_local_currency(other)
return MoneyPatched._patch_to_current_class(
super(MoneyPatched, self).__sub__(other))
def __mul__(self, other):
return MoneyPatched._patch_to_current_class(
super(MoneyPatched, self).__mul__(other))
def __eq__(self, other):
if hasattr(other, 'currency'):
if self.currency == other.currency:
return self.amount == other.amount
raise TypeError('Cannot add or subtract two Money instances with different currencies.')
return False
def __truediv__(self, other):
result = super(MoneyPatched, self).__truediv__(other)
if isinstance(other, Money):
return result
return self._patch_to_current_class(result)
def __rmod__(self, other):
return MoneyPatched._patch_to_current_class(
super(MoneyPatched, self).__rmod__(other))
def __get_current_locale(self):
# get_language can return None starting on django 1.8
language = translation.get_language() or settings.LANGUAGE_CODE
locale = translation.to_locale(language)
if _FORMATTER.get_formatting_definition(locale):
return locale
if _FORMATTER.get_formatting_definition('%s_%s' % (locale, locale)):
return '%s_%s' % (locale, locale)
return ''
def __use_l10n(self):
"""
Return boolean.
"""
if self.use_l10n is None:
return settings.USE_L10N
return self.use_l10n
def __unicode__(self):
if self.__use_l10n():
locale = self.__get_current_locale()
if locale:
return format_money(self, locale=locale)
return format_money(self)
__str__ = __unicode__
def __repr__(self):
return '%s %s' % (self.amount.to_integral_value(ROUND_DOWN), self.currency)
def get_value(obj, expr):
"""
Extracts value from object or expression.
"""
if isinstance(expr, F):
expr = getattr(obj, expr.name)
elif hasattr(expr, 'value'):
expr = expr.value
return expr
def validate_money_expression(obj, expr):
"""
Money supports different types of expressions, but you can't do following:
- Add or subtract money with not-money
- Any exponentiation
- Any operations with money in different currencies
- Multiplication, division, modulo with money instances on both sides of expression
"""
lhs, rhs = split_expression(expr)
connector = expr.connector
lhs = get_value(obj, lhs)
rhs = get_value(obj, rhs)
if (not isinstance(rhs, Money) and connector in ('+', '-')) or connector == '^':
raise ValidationError('Invalid F expression for MoneyField.', code='invalid')
if isinstance(lhs, Money) and isinstance(rhs, Money):
if connector in ('*', '/', '^', '%%'):
raise ValidationError('Invalid F expression for MoneyField.', code='invalid')
if lhs.currency != rhs.currency:
raise ValidationError('You cannot use F() with different currencies.', code='invalid')
def validate_money_value(value):
"""
Valid value for money are:
- Single numeric value
- Money instances
- Pairs of numeric value and currency. Currency can't be None.
"""
if isinstance(value, (list, tuple)) and (len(value) != 2 or value[1] is None):
raise ValidationError(
'Invalid value for MoneyField: %(value)s.',
code='invalid',
params={'value': value},
)
def get_currency(value):
"""
Extracts currency from value.
"""
if isinstance(value, Money):
return smart_unicode(value.currency)
elif isinstance(value, (list, tuple)):
return value[1]
class MoneyFieldProxy(object):
def __init__(self, field):
self.field = field
self.currency_field_name = get_currency_field_name(self.field.name)
def _money_from_obj(self, obj):
amount = obj.__dict__[self.field.name]
currency = obj.__dict__[self.currency_field_name]
if amount is None:
return None
return MoneyPatched(amount=amount, currency=currency)
def __get__(self, obj, type=None):
if obj is None:
raise AttributeError('Can only be accessed via an instance.')
data = obj.__dict__
if isinstance(data[self.field.name], BaseExpression):
return data[self.field.name]
if not isinstance(data[self.field.name], Money):
data[self.field.name] = self._money_from_obj(obj)
return data[self.field.name]
def __set__(self, obj, value): # noqa
if isinstance(value, BaseExpression):
if Value and isinstance(value, Value):
value = self.prepare_value(obj, value.value)
elif Func and isinstance(value, Func):
pass
else:
validate_money_expression(obj, value)
prepare_expression(value)
else:
value = self.prepare_value(obj, value)
obj.__dict__[self.field.name] = value
def prepare_value(self, obj, value):
validate_money_value(value)
currency = get_currency(value)
if currency:
self.set_currency(obj, currency)
return self.field.to_python(value)
def set_currency(self, obj, value):
# we have to determine whether to replace the currency.
# i.e. if we do the following:
# .objects.get_or_create(money_currency='EUR')
# then the currency is already set up, before this code hits
# __set__ of MoneyField. This is because the currency field
# has less creation counter than money field.
object_currency = obj.__dict__[self.currency_field_name]
default_currency = str(self.field.default_currency)
if object_currency != value and (object_currency == default_currency or value != default_currency):
# in other words, update the currency only if it wasn't
# changed before.
setattr(obj, self.currency_field_name, value)
class CurrencyField(models.CharField):
description = 'A field which stores currency.'
def __init__(self, price_field=None, verbose_name=None, name=None,
default=DEFAULT_CURRENCY, **kwargs):
if isinstance(default, Currency):
default = default.code
kwargs['max_length'] = 3
self.price_field = price_field
self.frozen_by_south = kwargs.pop('frozen_by_south', False)
super(CurrencyField, self).__init__(verbose_name, name, default=default,
**kwargs)
def contribute_to_class(self, cls, name):
if not self.frozen_by_south and name not in [f.name for f in cls._meta.fields]:
super(CurrencyField, self).contribute_to_class(cls, name)
class MoneyField(models.DecimalField):
description = 'A field which stores both the currency and amount of money.'
def __init__(self, verbose_name=None, name=None,
max_digits=None, decimal_places=None,
default=None,
default_currency=DEFAULT_CURRENCY,
currency_choices=CURRENCY_CHOICES, **kwargs):
nullable = kwargs.get('null', False)
default = self.setup_default(default, default_currency, nullable)
if not default_currency:
default_currency = default.currency
if VERSION < (1, 7):
self.check_field_attributes(decimal_places, max_digits)
self.default_currency = default_currency
self.currency_choices = currency_choices
self.frozen_by_south = kwargs.pop('frozen_by_south', False)
super(MoneyField, self).__init__(verbose_name, name, max_digits, decimal_places, default=default, **kwargs)
def setup_default(self, default, default_currency, nullable):
if default is None and not nullable:
# Backwards compatible fix for non-nullable fields
default = 0.0
if isinstance(default, string_types):
try:
# handle scenario where default is formatted like:
# 'amount currency-code'
amount, currency = default.split(' ')
except ValueError:
# value error would be risen if the default is
# without the currency part, i.e
# 'amount'
amount = default
currency = default_currency
default = Money(Decimal(amount), Currency(code=currency))
elif isinstance(default, (float, Decimal, int)):
default = Money(default, default_currency)
if not (nullable and default is None) and not isinstance(default, Money):
raise ValueError('default value must be an instance of Money, is: %s' % default)
return default
def check_field_attributes(self, decimal_places, max_digits):
"""
Django < 1.7 has no system checks framework.
Avoid giving the user hard-to-debug errors if they miss required attributes.
"""
if max_digits is None:
raise ValueError('You have to provide a max_digits attribute to Money fields.')
if decimal_places is None:
raise ValueError('You have to provide a decimal_places attribute to Money fields.')
def to_python(self, value):
if isinstance(value, Money):
value = value.amount
if isinstance(value, tuple):
value = value[0]
if isinstance(value, float):
value = str(value)
return super(MoneyField, self).to_python(value)
def contribute_to_class(self, cls, name):
cls._meta.has_money_field = True
if not self.frozen_by_south:
self.add_currency_field(cls, name)
super(MoneyField, self).contribute_to_class(cls, name)
setattr(cls, self.name, MoneyFieldProxy(self))
def add_currency_field(self, cls, name):
"""
Adds CurrencyField instance to a model class.
"""
currency_field = CurrencyField(
max_length=3, price_field=self,
default=self.default_currency, editable=False,
choices=self.currency_choices
)
currency_field.creation_counter = self.creation_counter
self.creation_counter += 1
currency_field_name = get_currency_field_name(name)
cls.add_to_class(currency_field_name, currency_field)
def get_db_prep_save(self, value, connection):
if isinstance(value, Expression):
return value
if isinstance(value, Money):
value = value.amount
return super(MoneyField, self).get_db_prep_save(value, connection)
def validate_lookup(self, lookup):
if lookup not in SUPPORTED_LOOKUPS:
raise NotSupportedLookup(lookup)
def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False):
self.validate_lookup(lookup_type)
value = self.get_db_prep_save(value, connection)
return super(MoneyField, self).get_db_prep_lookup(lookup_type, value, connection, prepared)
def get_lookup(self, lookup_name):
self.validate_lookup(lookup_name)
return super(MoneyField, self).get_lookup(lookup_name)
def get_default(self):
if isinstance(self.default, Money):
frm = inspect.stack()[1]
mod = inspect.getmodule(frm[0])
# We need to return the numerical value if this is called by south
if mod.__name__.startswith('south.db'):
return self.default.amount
return self.default
else:
return super(MoneyField, self).get_default()
def formfield(self, **kwargs):
defaults = {'form_class': forms.MoneyField}
defaults.update(kwargs)
defaults['currency_choices'] = self.currency_choices
defaults['default_currency'] = self.default_currency
return super(MoneyField, self).formfield(**defaults)
def value_to_string(self, obj):
value = self._get_val_from_obj(obj)
return self.get_prep_value(value)
# South support
def south_field_triple(self):
"""Returns a suitable description of this field for South."""
# Note: This method gets automatically with schemamigration time.
from south.modelsinspector import introspector
field_class = self.__class__.__module__ + '.' + self.__class__.__name__
args, kwargs = introspector(self)
# We need to
# 1. Delete the default, 'cause it's not automatically supported.
kwargs.pop('default')
# 2. add the default currency, because it's not picked up from the inspector automatically.
kwargs['default_currency'] = "'%s'" % self.default_currency
return field_class, args, kwargs
# Django 1.7 migration support
def deconstruct(self):
name, path, args, kwargs = super(MoneyField, self).deconstruct()
if self.default is not None:
kwargs['default'] = self.default.amount
if self.default_currency != DEFAULT_CURRENCY:
kwargs['default_currency'] = str(self.default_currency)
if self.currency_choices != CURRENCY_CHOICES:
kwargs['currency_choices'] = self.currency_choices
return name, path, args, kwargs
try:
from south.modelsinspector import add_introspection_rules
rules = [
# MoneyField has its own method.
((CurrencyField,),
[], # No positional args
{'default': ('default', {'default': DEFAULT_CURRENCY.code}),
'max_length': ('max_length', {'default': 3})}),
]
# MoneyField implement the serialization in south_field_triple method
add_introspection_rules(rules, ['^djmoney\.models\.fields\.CurrencyField'])
except ImportError:
pass
def patch_managers(sender, **kwargs):
"""
Patches models managers.
"""
if sender._meta.proxy_for_model:
has_money_field = hasattr(sender._meta.proxy_for_model._meta, 'has_money_field')
else:
has_money_field = hasattr(sender._meta, 'has_money_field')
if has_money_field:
setup_managers(sender)
class_prepared.connect(patch_managers)