Skip to content

Commit

Permalink
Support for indirect rates conversion (#428)
Browse files Browse the repository at this point in the history
* Support for indirect rates conversion

* Fix

* Support for indirect rates conversion

* Add a comment about rates swap

* Update comment from feedback

* Fix lint
  • Loading branch information
Stranger6667 committed Jun 28, 2018
1 parent 72913c4 commit 097caa2
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 34 deletions.
64 changes: 44 additions & 20 deletions djmoney/contrib/exchange/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,6 @@ def get_default_backend_name():
return import_string(EXCHANGE_BACKEND).name


def get_one():
"""
For SQLite it is required to cast value to NUMERIC type, otherwise integer division will be used.
"""
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3':
return '1::NUMERIC'
return 1


def get_rate(source, target, backend=None):
"""
Returns an exchange rate between source and target currencies.
Expand All @@ -62,19 +53,52 @@ def get_rate(source, target, backend=None):


def _get_rate(source, target, backend):
if text_type(source) == text_type(target):
source, target = text_type(source), text_type(target)
if text_type(source) == target:
return 1
try:
forward = models.Q(currency=target, backend__base_currency=source)
reverse = models.Q(currency=source, backend__base_currency=target)
return Rate.objects.annotate(
rate=models.Case(
models.When(forward, then=models.F('value')),
models.When(reverse, then=models.Value(get_one()) / models.F('value')),
)
).get(forward | reverse, backend=backend).rate
except Rate.DoesNotExist:
rates = Rate.objects.filter(currency__in=(source, target), backend=backend).select_related('backend')
if not rates:
raise MissingRate('Rate %s -> %s does not exist' % (source, target))
if len(rates) == 1:
return _try_to_get_rate_directly(source, target, rates[0])
return _get_rate_via_base(rates, target)


def _try_to_get_rate_directly(source, target, rate):
"""
Either target or source equals to base currency of existing rate.
"""
# Converting from base currency to target
if rate.backend.base_currency == source and rate.currency == target:
return rate.value
# Converting from target currency to base
elif rate.backend.base_currency == target and rate.currency == source:
return 1 / rate.value
# Case when target or source is not a base currency
raise MissingRate('Rate %s -> %s does not exist' % (source, target))


def _get_rate_via_base(rates, target):
"""
:param: rates: A set/tuple of two base Rate instances
:param: target: A string instance of the currency to convert to
Both target and source are not a base currency - actual rate could be calculated via their rates to base currency.
For example:
7.84 NOK = 1 USD = 8.37 SEK
7.84 NOK = 8.37 SEK
1 NOK = 8.37 / 7.84 SEK
"""
first, second = rates
# Instead of expecting an explicit order in the `rates` iterable, that will put the
# source currency in the first place, we decided to add an extra check here and swap
# items if they are ordered not as expected
if first.currency == target:
first, second = second, first
return second.value / first.value


def convert_money(value, currency):
Expand Down
8 changes: 8 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ Changelog
Unreleased
----------

Added
~~~~~

- Support for indirect rates conversion through maximum 1 extra step (when there is no direct conversion rate:
converting by means of a third currency for which both source and target currency have conversion
rates). `#425`_ (`Stranger6667`_, `77cc33`_)

Fixed
~~~~~

Expand Down Expand Up @@ -563,6 +570,7 @@ Added
.. _0.2: https://github.com/django-money/django-money/compare/0.2...a6d90348085332a393abb40b86b5dd9505489b04

.. _#427: https://github.com/django-money/django-money/pull/427
.. _#425: https://github.com/django-money/django-money/issues/425
.. _#412: https://github.com/django-money/django-money/issues/412
.. _#410: https://github.com/django-money/django-money/issues/410
.. _#402: https://github.com/django-money/django-money/issues/402
Expand Down
19 changes: 16 additions & 3 deletions tests/contrib/exchange/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from contextlib import contextmanager
from decimal import Decimal

from django.core.cache import cache
Expand Down Expand Up @@ -36,6 +37,14 @@
FIXER_EXPECTED = json.loads(FIXER_RESPONSE, parse_float=Decimal)['rates']


@contextmanager
def mock_backend(value):
response = Mock()
response.read.return_value = value
with patch('djmoney.contrib.exchange.backends.base.urlopen', return_value=response):
yield


class ExchangeTest:

@pytest.fixture(autouse=True, params=(
Expand All @@ -46,9 +55,7 @@ def setup(self, request):
klass, response_value, expected = request.param
self.backend = klass()
self.expected = expected
response = Mock()
response.read.return_value = response_value
with patch('djmoney.contrib.exchange.backends.base.urlopen', return_value=response):
with mock_backend(response_value):
yield

def assert_rates(self):
Expand Down Expand Up @@ -83,6 +90,12 @@ def simple_rates(backend):
Rate.objects.create(currency='EUR', value=2, backend=backend)


@pytest.fixture
def default_openexchange_rates():
with mock_backend(OPEN_EXCHANGE_RATES_RESPONSE):
OpenExchangeRatesBackend().update_rates()


@pytest.fixture(autouse=True)
def django_cache():
cache.clear()
Expand Down
49 changes: 38 additions & 11 deletions tests/contrib/exchange/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,48 @@
pytestmark = pytest.mark.django_db


@pytest.mark.parametrize('source, target, expected', (
('USD', 'USD', 1),
('USD', 'EUR', 2),
('EUR', 'USD', Decimal('0.5')),
(Currency('USD'), 'USD', 1),
('USD', Currency('USD'), 1),
@pytest.mark.parametrize('source, target, expected, queries', (
('USD', 'USD', 1, 0),
('USD', 'EUR', 2, 1),
('EUR', 'USD', Decimal('0.5'), 1),
(Currency('USD'), 'USD', 1, 0),
('USD', Currency('USD'), 1, 0),
))
@pytest.mark.usefixtures('simple_rates')
def test_get_rate(source, target, expected):
assert get_rate(source, target) == expected
def test_get_rate(source, target, expected, django_assert_num_queries, queries):
with django_assert_num_queries(queries):
assert get_rate(source, target) == expected


@pytest.mark.parametrize('source, target, expected', (
('NOK', 'SEK', Decimal('1.067732610555839085161462146')),
('SEK', 'NOK', Decimal('0.9365640705489186883886537319')),
))
@pytest.mark.usefixtures('default_openexchange_rates')
def test_rates_via_base(source, target, expected, django_assert_num_queries):
with django_assert_num_queries(1):
assert get_rate(source, target) == expected


def test_unknown_currency():
with pytest.raises(MissingRate, match='Rate USD \\-\\> EUR does not exist'):
get_rate('USD', 'EUR')
@pytest.mark.parametrize('source, target', (
('NOK', 'ZAR'),
('ZAR', 'NOK'),
('USD', 'ZAR'),
('ZAR', 'USD'),
))
@pytest.mark.usefixtures('default_openexchange_rates')
def test_unknown_currency_with_partially_exiting_currencies(source, target):
with pytest.raises(MissingRate, match='Rate %s \\-\\> %s does not exist' % (source, target)):
get_rate(source, target)


@pytest.mark.parametrize('source, target', (
('USD', 'EUR'),
('SEK', 'ZWL')
))
def test_unknown_currency(source, target):
with pytest.raises(MissingRate, match='Rate %s \\-\\> %s does not exist' % (source, target)):
get_rate(source, target)


def test_string_representation(backend):
Expand Down

0 comments on commit 097caa2

Please sign in to comment.