├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── django_easy_currencies ├── __init__.py ├── admin.py ├── context_processors.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── currencies.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20141017_0841.py │ └── __init__.py ├── models │ ├── Currency.py │ ├── CurrencyRate.py │ └── __init__.py ├── templates │ └── currencies_combo.html ├── templatetags │ ├── __init__.py │ └── currencies.py ├── tests │ ├── TestChangeCurrencyView.py │ ├── TestCurrenciesCommand.py │ ├── TestCurrenciesTemplateTags.py │ ├── TestCurrencyContextProcessor.py │ ├── TestCurrencyConverter.py │ └── __init__.py ├── urls.py ├── utils.py └── views │ ├── ChangeCurrencyView.py │ └── __init__.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | .idea 4 | ENV 5 | dist 6 | *.egg-info 7 | build 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014 Davide Zanotti, http://www.daveoncode.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include django_easy_currencies * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Easy Currencies 2 | 3 | Simple app to manage currencies conversion in Django using [openexchangerates.org](https://openexchangerates.org) 4 | service. 5 | 6 | The app will automatically invokes the service and in a **single HTTP call** it will creates all the necessary conversion rates permutations offline **by "bypassing" the free account limitation which limits the source currency to USD** using simple math alghoritms and the excellent Python's `itertools` utilities (so this is 100% legal!). 7 | 8 | --- 9 | 10 | ## Quick start 11 | 12 | ### Installation 13 | 14 | `pip install django-easy-currencies` 15 | 16 | ### Setup 17 | 18 | 1. Add "django_easy_currencies" to your `INSTALLED_APPS` setting like this: 19 | 20 | ``` 21 | INSTALLED_APPS = ( 22 | ... 23 | 'django_easy_currencies', 24 | ) 25 | ``` 26 | 27 | 2. Get an app key from [openexchangerates.org](https://openexchangerates.org) (you don't need to pay, the basic free account will be enough) 28 | 29 | 3. Configure the app by providing your app id and the currencies you want to use like this: 30 | 31 | ``` 32 | EASY_CURRENCIES = { 33 | 'currencies': ( 34 | ('USD', 'US Dollar'), 35 | ('EUR', 'Euro'), 36 | ('GBP', 'British Pound'), 37 | ('AUD', 'Australian Dollar'), 38 | ('CAD', 'Canadian Dollar'), 39 | ('CHF', 'Swiss Franc'), 40 | ('JPY', 'Japanese Yen'), 41 | ), 42 | 'app_id': os.environ['EASY_CURRENCIES_APP_ID'] 43 | } 44 | ``` 45 | **Just a note**: *An environment variable holding your app id is a best practice but is not mandatory, you can define it inline in your settings.py* 46 | 47 | 4. Include the "**django_easy_currencies**" URLconf in your project urls.py like this: 48 | 49 | ``` 50 | url(r'^currency/', include('django_easy_currencies.urls')), 51 | ``` 52 | 53 | 5. Add the "**django_easy_currencies**" context processor to your existent processors like this: 54 | 55 | ``` 56 | from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS as BASE_CONTEXT_PROCESSORS 57 | 58 | TEMPLATE_CONTEXT_PROCESSORS = BASE_CONTEXT_PROCESSORS + ( 59 | 'django_easy_currencies.context_processors.currency', 60 | ) 61 | ``` 62 | 63 | 6. Run `python manage.py migrate` to create the app models. 64 | 65 | 7. Run the custom management command `python manage.py currencies --update` to save currency rates in your database. 66 | *You should run this command at least once a day in order to have updated rates (automatization of this step is up to you)* 67 | 68 | 8. (Optional) Run `python manage.py currencies --list` to see the loaded currency rates 69 | 70 | ### Change active currency 71 | 72 | The default currency automatically activated by the context processor is "**USD**". 73 | To change it "**django_easy_currencies**" provides a custom tag which prints a combo with all the available currencies and calls `ChangeCurrencyView` as soon the user select a new option. 74 | To use the tag you just need to: 75 | 76 | 1. Load the tag library: 77 | 78 | `{% load currencies %}` 79 | 80 | 2. Use the tag: 81 | 82 | `{% currencies_combo %}` 83 | 84 | 85 | 86 | ### Display localized currencies in templates 87 | 88 | 1. Load the tag library: 89 | 90 | `{% load currencies %}` 91 | 2. Use the custom tag to display the converted price: 92 | 93 | `{% local_currency original_price original_currency %}` 94 | 95 | The tag will convert the `original_price` using `original_currency` into the current active currency (which is available in template context as "`active_currency`"). And formatting it with the right currency symbol. 96 | 97 | So, supposing you are going to print a localized book price which originally is **39.50 USD** and the active 98 | currency is **EUR**, the result will be something like: **€ 31,26**. 99 | And in the template it will looks like: 100 | 101 | `{% local_currency book.price 'USD' %}` 102 | 103 | or 104 | 105 | `{% local_currency book.price book.original_currency %}` 106 | 107 | It's also possible to skip number formatting by passing `False` as the third tag argument: 108 | 109 | `{% local_currency book.price 'USD' False %}` 110 | 111 | In this way the output will be simply: **31.26** 112 | 113 | 114 | **If you use `local_currency` tag before to run `currencies --update` command, it will silently returns the original 115 | price without conversion.** 116 | 117 | --- 118 | 119 | ### Converting prices in your business logic (outside of Django templates) 120 | 121 | To convert prices programmatically you can use the `CurrencyConverter` class: 122 | 123 | ``` 124 | from django_easy_currencies.utils import CurrencyConverter 125 | 126 | # ("EUR" is the target currency into which you wish to convert prices) 127 | converter = CurrencyConverter('EUR') 128 | 129 | # (the first parameter is the price to convert, the second the original currency) 130 | price1 = converter.convert('99.9', 'USD') 131 | ``` 132 | 133 | **If you use the converter before to run `currencies --update` command, it will raise an `CurrencyConverterException`**. 134 | 135 | --- 136 | 137 | ## Credits 138 | 139 | **django-easy-currencies** developed by Davide Zanotti - [daveoncode.com](http://www.daveoncode.com) - released under the [MIT license](https://github.com/daveoncode/django-easy-currencies/blob/master/LICENSE). 140 | -------------------------------------------------------------------------------- /django_easy_currencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daveoncode/django-easy-currencies/1da1d8e3f411c0c2aa10aee29069988ac40f7bae/django_easy_currencies/__init__.py -------------------------------------------------------------------------------- /django_easy_currencies/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from models import Currency, CurrencyRate 4 | 5 | 6 | class CurrencyRateAdmin(admin.ModelAdmin): 7 | list_display = ('original_currency', 'target_currency', 'rate') 8 | list_filter = ('original_currency', 'target_currency') 9 | 10 | 11 | admin.site.register(Currency) 12 | admin.site.register(CurrencyRate, CurrencyRateAdmin) 13 | -------------------------------------------------------------------------------- /django_easy_currencies/context_processors.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django_easy_currencies.models.CurrencyRate import CurrencyRate 3 | 4 | 5 | def currency(request): 6 | """ 7 | Add active_currency and currency_rates rates into context_data. 8 | 9 | :param request: 10 | :return: :rtype: 11 | """ 12 | cur = request.session.get('currency', 'USD') 13 | rates = CurrencyRate.objects.get_rate_values(cur) # todo: handle caching 14 | return { 15 | 'active_currency': cur, 16 | 'currency_rates': rates 17 | } 18 | -------------------------------------------------------------------------------- /django_easy_currencies/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daveoncode/django-easy-currencies/1da1d8e3f411c0c2aa10aee29069988ac40f7bae/django_easy_currencies/management/__init__.py -------------------------------------------------------------------------------- /django_easy_currencies/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daveoncode/django-easy-currencies/1da1d8e3f411c0c2aa10aee29069988ac40f7bae/django_easy_currencies/management/commands/__init__.py -------------------------------------------------------------------------------- /django_easy_currencies/management/commands/currencies.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from optparse import make_option 3 | from urllib2 import URLError 4 | import urllib2 5 | import json 6 | from itertools import product 7 | from decimal import Decimal 8 | 9 | from django.core.exceptions import ImproperlyConfigured 10 | from django.core.management.base import BaseCommand 11 | from django.conf import settings 12 | 13 | from django_easy_currencies.models.Currency import Currency 14 | from django_easy_currencies.models.CurrencyRate import CurrencyRate 15 | 16 | 17 | class Command(BaseCommand): 18 | help = 'Updates or list rates of supported currencies.' 19 | base_service_url = 'http://openexchangerates.org/api/latest.json?app_id={0}' 20 | option_list = BaseCommand.option_list + ( 21 | make_option('--update', 22 | action='store_true', 23 | dest='update', 24 | default=False, 25 | help='Update currency rates'), 26 | make_option('--list', 27 | action='store_true', 28 | dest='list', 29 | default=False, 30 | help='List current currency rates'), 31 | ) 32 | 33 | @staticmethod 34 | def is_valid_config(): 35 | c = getattr(settings, 'EASY_CURRENCIES', None) 36 | return isinstance(c, dict) and isinstance(c.get('currencies'), (list, tuple)) and bool(c.get('app_id')) 37 | 38 | def get_rates_info(self, url, currencies): 39 | """ 40 | Makes an http call to the rates service and returns a python dictionary. 41 | 42 | :param url: 43 | :param currencies: 44 | :return: :rtype: :raise exception: 45 | """ 46 | try: 47 | self.stdout.write('Calling service: {0}'.format(url)) 48 | response = urllib2.urlopen(url) 49 | if not response: 50 | raise Exception('Invalid response') 51 | info = json.loads(response.read(), parse_float=Decimal, parse_int=Decimal) 52 | info['rates'] = [(k, v) for k, v in info['rates'].items() if k in currencies] 53 | return info 54 | except URLError as url_error: 55 | self.stderr.write('Unable to connect to service {0}: {1}'.format(url, url_error)) 56 | raise url_error 57 | except Exception as exception: 58 | self.stderr.write('Unable to retrieve ratings info: {0}'.format(exception)) 59 | raise exception 60 | 61 | def create_or_update_currency_objects(self, currency_types): 62 | """ 63 | Creates records for Currency objects if not already defined and return them in a dictionary for later access. 64 | 65 | :param currency_types: 66 | :return: :rtype: 67 | """ 68 | self.stdout.write('Updating currency objects...') 69 | currencies = {} 70 | for c in currency_types: 71 | self.stdout.write('Updating currency: {0}'.format(c)) 72 | currency, created = Currency.objects.update_or_create(code=c, defaults={'code': c}) 73 | currencies[c] = currency 74 | return currencies 75 | 76 | def create_or_update_usd_currency_rates(self, info, usd_currency): 77 | """ 78 | Returns a dictionary containing CurrencyRate objects related to USD currency. 79 | 80 | :param info: 81 | :param usd_currency: 82 | :return: :rtype: 83 | """ 84 | rates = {} 85 | for rate_code, rate_value in info['rates']: 86 | self.stdout.write('Updating rates for currency: {0}'.format(rate_code)) 87 | rate_obj, _ = CurrencyRate.objects.update_or_create(original_currency=usd_currency, 88 | target_currency=rate_code, 89 | defaults={'rate': rate_value}) 90 | rates[rate_code] = rate_obj.rate 91 | return rates 92 | 93 | def create_or_update_inverted_usd_currency_rates(self, currencies, usd_rates): 94 | """ 95 | Save the inverted rates of USD rates (ie: from USD/EUR, USD/GBP... -> EUR/USD, GBP/USD...) 96 | 97 | :param currencies: 98 | :param usd_rates: 99 | """ 100 | self.stdout.write('Updating reversed rates for USD currency...') 101 | for code, currency_obj in currencies.items(): 102 | self.stdout.write('Updating rate {0}/USD'.format(code)) 103 | rate_value = Decimal('1') if code == 'USD' else usd_rates[code] 104 | CurrencyRate.objects.update_or_create(original_currency=currency_obj, 105 | target_currency='USD', 106 | defaults={'rate': rate_value}) 107 | 108 | def create_or_update_inverted_currency_rates_permutations(self, currencies, currency_types, usd_rates): 109 | """ 110 | Saves recursively all the possible currency rates. 111 | 112 | :param currencies: 113 | :param currency_types: 114 | :param usd_rates: 115 | """ 116 | self.stdout.write('Updating reversed rates permutations...') 117 | for p in [x for x in product(currency_types, repeat=2)]: 118 | from_currency, to_currency = p 119 | self.stdout.write('Updating rate {0}/{1}'.format(from_currency, to_currency)) 120 | if from_currency == to_currency: 121 | rate_value = Decimal('1') 122 | else: 123 | rate_value = usd_rates[to_currency] / usd_rates[from_currency] 124 | CurrencyRate.objects.update_or_create(original_currency=currencies[from_currency], 125 | target_currency=to_currency, 126 | defaults={'rate': rate_value}) 127 | 128 | @staticmethod 129 | def get_currency_list(): 130 | """ 131 | Returns a list of supported currencies without the USD (which is the default one) 132 | 133 | :return: :rtype: 134 | """ 135 | return [c[0] for c in settings.EASY_CURRENCIES['currencies']] 136 | 137 | def get_service_url(self): 138 | """ 139 | Returns the configured service url to call. 140 | 141 | :return: :rtype: 142 | """ 143 | return self.base_service_url.format(settings.EASY_CURRENCIES['app_id']) 144 | 145 | def update_currency_rates(self): 146 | """ 147 | Updates currencies/rates by following these steps: 148 | 1. Calls the remote service and retrieve the json response converted into a python dictionary 149 | 2. Retrieve base USD Currency (creates it if does not exist) 150 | 3. Retrieve extra USD currencies supported by configuration (creates them if not defined) 151 | 4. Creates/updates USD rates 152 | 5. Creates/updates all other supported currency rates recursively 153 | 154 | """ 155 | self.stdout.write('Updating currency rates...') 156 | currency_types = self.get_currency_list() 157 | info = self.get_rates_info(self.get_service_url(), currency_types) 158 | try: 159 | usd_currency, _ = Currency.objects.update_or_create(code='USD', defaults={'code': 'USD'}) 160 | currencies = self.create_or_update_currency_objects(currency_types) 161 | usd_rates = self.create_or_update_usd_currency_rates(info, usd_currency) 162 | self.create_or_update_inverted_usd_currency_rates(currencies, usd_rates) 163 | self.create_or_update_inverted_currency_rates_permutations(currencies, currency_types, usd_rates) 164 | self.stdout.write('Currency rates have been updated, run command with "--list" to see current status.') 165 | except Exception as e: 166 | self.stderr.write('An error occurred while updating currency rates: {0}'.format(e)) 167 | 168 | def list_current_currency_rates(self): 169 | if CurrencyRate.objects.count() < 1: 170 | self.stdout.write('No currency rates available... run the command using --update to add new rates') 171 | else: 172 | for r in CurrencyRate.objects.select_related().all(): 173 | self.stdout.write(unicode(r)) 174 | 175 | def handle(self, *args, **options): 176 | if not self.is_valid_config(): 177 | raise ImproperlyConfigured('EASY_CURRENCIES configuration is invalid') 178 | if options['update']: 179 | self.update_currency_rates() 180 | elif options['list']: 181 | self.list_current_currency_rates() 182 | -------------------------------------------------------------------------------- /django_easy_currencies/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.core.validators 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Currency', 15 | fields=[ 16 | ('code', models.CharField(primary_key=True, serialize=False, max_length=3, 17 | validators=[django.core.validators.MinLengthValidator(3), 18 | django.core.validators.MaxLengthValidator(3)], 19 | help_text='Currency code in ISO 4217 format ($ == USD)', db_index=True)), 20 | ], 21 | options={ 22 | 'db_table': 'django_easy_currencies_currency', 23 | 'verbose_name': 'Currency', 24 | 'verbose_name_plural': 'Currencies', 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | migrations.CreateModel( 29 | name='CurrencyRate', 30 | fields=[ 31 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 32 | ('target_currency', models.CharField(db_index=True, max_length=3, editable=False, 33 | validators=[django.core.validators.MinLengthValidator(3), 34 | django.core.validators.MaxLengthValidator(3)])), 35 | ('rate', models.FloatField()), 36 | ('original_currency', models.ForeignKey(related_name='rates', to='django_easy_currencies.Currency')), 37 | ], 38 | options={ 39 | 'db_table': 'django_easy_currencies_rate', 40 | 'verbose_name': 'Currency rate', 41 | 'verbose_name_plural': 'Currency rates', 42 | }, 43 | bases=(models.Model,), 44 | ), 45 | migrations.AlterUniqueTogether( 46 | name='currencyrate', 47 | unique_together=set([('original_currency', 'target_currency')]), 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /django_easy_currencies/migrations/0002_auto_20141017_0841.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('django_easy_currencies', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='currencyrate', 16 | name='rate', 17 | field=models.DecimalField(max_digits=13, decimal_places=9), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /django_easy_currencies/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daveoncode/django-easy-currencies/1da1d8e3f411c0c2aa10aee29069988ac40f7bae/django_easy_currencies/migrations/__init__.py -------------------------------------------------------------------------------- /django_easy_currencies/models/Currency.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.core.validators import MinLengthValidator, MaxLengthValidator 3 | from django.db import models 4 | 5 | 6 | class Currency(models.Model): 7 | class Meta: 8 | app_label = 'django_easy_currencies' 9 | db_table = 'django_easy_currencies_currency' 10 | verbose_name = 'Currency' 11 | verbose_name_plural = 'Currencies' 12 | 13 | code = models.CharField(max_length=3, 14 | validators=[MinLengthValidator(3), MaxLengthValidator(3)], 15 | help_text='Currency code in ISO 4217 format ($ == USD)', 16 | db_index=True, 17 | primary_key=True) 18 | 19 | def __unicode__(self): 20 | return self.code 21 | 22 | def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 23 | if self.code: 24 | self.code = self.code.strip().upper() 25 | super(Currency, self).save(force_insert, force_update, using, update_fields) 26 | -------------------------------------------------------------------------------- /django_easy_currencies/models/CurrencyRate.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.core.validators import MinLengthValidator, MaxLengthValidator 3 | from django.db import models 4 | 5 | 6 | class CurrencyRateManager(models.Manager): 7 | def get_rate_values(self, currency): 8 | """ 9 | Returns a dictionary containing conversion rates for the given currency. 10 | 11 | :param currency: 12 | :return: :rtype: 13 | """ 14 | records = self.select_related().values().filter(original_currency__code=currency) 15 | rates = {} 16 | for r in records: 17 | rates[r['target_currency']] = r['rate'] 18 | return rates 19 | 20 | 21 | class CurrencyRate(models.Model): 22 | class Meta: 23 | app_label = 'django_easy_currencies' 24 | db_table = 'django_easy_currencies_rate' 25 | verbose_name = 'Currency rate' 26 | verbose_name_plural = 'Currency rates' 27 | unique_together = ( 28 | ('original_currency', 'target_currency'), 29 | ) 30 | 31 | original_currency = models.ForeignKey('django_easy_currencies.Currency', related_name='rates') 32 | target_currency = models.CharField(max_length=3, 33 | validators=[MinLengthValidator(3), MaxLengthValidator(3)], 34 | db_index=True, 35 | editable=False) 36 | rate = models.DecimalField(max_digits=13, decimal_places=9) # (max: 9999.999999999) 37 | 38 | # custom manager 39 | objects = CurrencyRateManager() 40 | 41 | def __unicode__(self): 42 | return '{0}/{1}: {2}'.format(self.original_currency.code, self.target_currency, self.rate) 43 | 44 | def save(self, force_insert=False, force_update=False, using=None, update_fields=None): 45 | if self.target_currency: 46 | self.target_currency = self.target_currency.strip().upper() 47 | super(CurrencyRate, self).save(force_insert, force_update, using, update_fields) 48 | -------------------------------------------------------------------------------- /django_easy_currencies/models/__init__.py: -------------------------------------------------------------------------------- 1 | from Currency import Currency 2 | from CurrencyRate import CurrencyRate 3 | -------------------------------------------------------------------------------- /django_easy_currencies/templates/currencies_combo.html: -------------------------------------------------------------------------------- 1 |
2 | {% csrf_token %} 3 | 10 |
11 | -------------------------------------------------------------------------------- /django_easy_currencies/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daveoncode/django-easy-currencies/1da1d8e3f411c0c2aa10aee29069988ac40f7bae/django_easy_currencies/templatetags/__init__.py -------------------------------------------------------------------------------- /django_easy_currencies/templatetags/currencies.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django import template 4 | from django.conf import settings 5 | from django.template.base import TemplateSyntaxError 6 | from django.utils.functional import cached_property 7 | 8 | from babel.numbers import format_currency 9 | 10 | 11 | register = template.Library() 12 | 13 | 14 | class CurrencyConversionNode(template.Node): 15 | def __init__(self, price, source_currency, formatted=True): 16 | """ 17 | 18 | :param price: Price to convert. 19 | :param source_currency: Original currency of the price to convert. 20 | :param formatted: True to return a locale-formatted string, False to return a Decimal instance. 21 | """ 22 | self.original_price_var = price 23 | self.source_currency_var = source_currency 24 | self.formatted = True if str(formatted) == 'True' else False # ("formatted" is received as string from tag) 25 | self.context = None 26 | 27 | def resolve_var(self, var): 28 | """ 29 | Returns the value of the given variable using existent template context. 30 | 31 | :param var: 32 | :return: :rtype: 33 | """ 34 | return template.Variable(var).resolve(self.context) 35 | 36 | @cached_property 37 | def active_currency(self): 38 | return self.resolve_var('active_currency') # (inject into template by context processor) 39 | 40 | @cached_property 41 | def currency_rates(self): 42 | return self.resolve_var('currency_rates') # (inject into template by context processor) 43 | 44 | def render(self, context): 45 | self.context = context 46 | price = self.resolve_var(self.original_price_var) 47 | source_currency = self.resolve_var(self.source_currency_var) 48 | try: 49 | converted_price = price / self.currency_rates[source_currency] 50 | if self.formatted: 51 | return format_currency(converted_price, self.active_currency) 52 | except KeyError: 53 | if self.formatted: 54 | return format_currency(price, source_currency) 55 | converted_price = price 56 | return unicode(converted_price) 57 | 58 | 59 | @register.tag 60 | def local_currency(parser, token): 61 | """ 62 | Returns a price converted to the current active currency from the original currency. 63 | 64 | :param parser: 65 | :param token: 66 | :return: :rtype: :raise TemplateSyntaxError: 67 | """ 68 | params = token.split_contents()[1:] 69 | count = len(params) 70 | if count < 2: 71 | msg = 'Invalid number of arguments ({0}), must be at least 2: price, currency.' 72 | raise TemplateSyntaxError(msg.format(count)) 73 | return CurrencyConversionNode(*params) 74 | 75 | 76 | @register.inclusion_tag('currencies_combo.html', takes_context=True) 77 | def currencies_combo(context): 78 | """ 79 | Render a simple combo that lists available currencies and allows to switch among them. 80 | 81 | :param context: 82 | :return: :rtype: 83 | """ 84 | context.update({'currencies': settings.EASY_CURRENCIES['currencies']}) 85 | return context 86 | -------------------------------------------------------------------------------- /django_easy_currencies/tests/TestChangeCurrencyView.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.core.urlresolvers import reverse 4 | from django.test.testcases import TestCase 5 | 6 | from django.http.response import HttpResponseNotAllowed 7 | 8 | 9 | class TestChangeCurrencyView(TestCase): 10 | def setUp(self): 11 | self.url = reverse('change_currency') 12 | 13 | def test_get_is_not_allowed(self): 14 | response = self.client.get(self.url) 15 | self.assertIsInstance(response, HttpResponseNotAllowed) 16 | 17 | def test_view_set_received_currency_in_session(self): 18 | self.assertIsNone(self.client.session.get('currency')) 19 | self.client.post(self.url, {'currency': 'EUR'}) 20 | self.assertEqual(self.client.session.get('currency'), 'EUR') 21 | 22 | def test_view_set_usd_as_default_currency_if_currency_was_not_defined(self): 23 | self.assertIsNone(self.client.session.get('currency')) 24 | self.client.post(self.url, {}) 25 | self.assertEqual(self.client.session.get('currency'), 'USD') 26 | -------------------------------------------------------------------------------- /django_easy_currencies/tests/TestCurrenciesCommand.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from itertools import product 3 | import os 4 | from urllib2 import URLError 5 | from decimal import Decimal 6 | 7 | from django.test.testcases import TestCase 8 | from django.test.utils import override_settings 9 | 10 | from django_easy_currencies.management.commands.currencies import Command 11 | from django_easy_currencies.models.Currency import Currency 12 | from django_easy_currencies.models.CurrencyRate import CurrencyRate 13 | 14 | 15 | VALID_SETTINGS = { 16 | 'app_id': os.environ['EASY_CURRENCIES_APP_ID'], 17 | 'currencies': ( 18 | ('USD', 'Dollars'), 19 | ('EUR', 'Euro'), 20 | ('GBP', 'Pounds'), 21 | ) 22 | } 23 | 24 | 25 | class OutputMock(object): 26 | @staticmethod 27 | def write(message): 28 | pass 29 | 30 | 31 | class TestCurrenciesCommand(TestCase): 32 | def setUp(self): 33 | self.command = Command() 34 | self.command.stdout = OutputMock() 35 | self.command.stderr = OutputMock() 36 | 37 | @override_settings(EASY_CURRENCIES=None) 38 | def test_is_valid_config_returns_false_if_config_is_none(self): 39 | self.assertFalse(self.command.is_valid_config()) 40 | 41 | @override_settings(EASY_CURRENCIES={}) 42 | def test_is_valid_config_returns_false_if_config_is_empty(self): 43 | self.assertFalse(self.command.is_valid_config()) 44 | 45 | @override_settings(EASY_CURRENCIES={'currencies': (('USD', 'Dollars'), ('EUR', 'Euro'))}) 46 | def test_is_valid_config_returns_false_if_config_is_missing_app_id(self): 47 | self.assertFalse(self.command.is_valid_config()) 48 | 49 | @override_settings(EASY_CURRENCIES={'app_id': '1234567890'}) 50 | def test_is_valid_config_returns_false_if_config_is_missing_currencies(self): 51 | self.assertFalse(self.command.is_valid_config()) 52 | 53 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 54 | def test_is_valid_config_returns_true_if_currencies_and_app_id_are_defined(self): 55 | self.assertTrue(self.command.is_valid_config()) 56 | 57 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 58 | def test_get_currency_list_returns_expectd_list(self): 59 | expected = ['USD', 'EUR', 'GBP'] 60 | self.assertEqual(self.command.get_currency_list(), expected) 61 | 62 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 63 | def test_get_service_url_returns_expected_url(self): 64 | expected = 'http://openexchangerates.org/api/latest.json?app_id={}'.format(VALID_SETTINGS['app_id']) 65 | self.assertEqual(self.command.get_service_url(), expected) 66 | 67 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 68 | def test_get_rates_info_returns_expected_list_of_tuples(self): 69 | currencies = self.command.get_currency_list() 70 | info = self.command.get_rates_info(self.command.get_service_url(), currencies) 71 | self.assertIsInstance(info, dict) 72 | self.assertIsInstance(info['rates'], list) 73 | self.assertEqual(len(info['rates']), len(currencies)) 74 | for r in info['rates']: 75 | self.assertIsInstance(r, tuple) 76 | self.assertTrue(r[0] in currencies) 77 | self.assertIsInstance(r[1], Decimal) 78 | 79 | @override_settings(EASY_CURRENCIES={'currencies': (('USD', 'Dollars'), ('EUR', 'Euro')), 'app_id': 'invalid-code'}) 80 | def test_get_rates_raise_exception_if_provided_app_id_is_invalid(self): 81 | def bad(): 82 | self.command.get_rates_info(self.command.get_service_url(), self.command.get_currency_list()) 83 | 84 | self.assertRaises(URLError, bad) 85 | 86 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 87 | def test_create_or_update_currency_objects_creates_expected_records(self): 88 | self.assertEqual(Currency.objects.count(), 0) 89 | currencies = self.command.get_currency_list() 90 | self.command.create_or_update_currency_objects(currencies) 91 | self.assertEqual(Currency.objects.count(), len(currencies)) 92 | 93 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 94 | def test_create_or_update_currency_returns_dictionry_with_models(self): 95 | currencies = self.command.get_currency_list() 96 | res = self.command.create_or_update_currency_objects(currencies) 97 | self.assertIsInstance(res, dict) 98 | for c in currencies: 99 | self.assertTrue(c in res, '"{}" not in dictionary'.format(c)) 100 | currency = res.get(c) 101 | self.assertIsInstance(currency, Currency) 102 | self.assertEqual(currency.code, c) 103 | 104 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 105 | def test_create_or_update_usd_currency_rates_creates_expected_records(self): 106 | self.assertEqual(CurrencyRate.objects.count(), 0) 107 | currencies = self.command.get_currency_list() 108 | info = self.command.get_rates_info(self.command.get_service_url(), currencies) 109 | usd_currency, _ = Currency.objects.update_or_create(code='USD', defaults={'code': 'USD'}) 110 | self.command.create_or_update_usd_currency_rates(info, usd_currency) 111 | records = CurrencyRate.objects.all() 112 | for record in records: 113 | self.assertIsInstance(record, CurrencyRate) 114 | self.assertEqual(record.original_currency, usd_currency) 115 | self.assertTrue(record.target_currency in currencies) 116 | self.assertEqual(len(records), len(currencies)) 117 | 118 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 119 | def test_create_or_update_usd_currency_rates_returns_dictionary_with_rate_values(self): 120 | self.assertEqual(CurrencyRate.objects.count(), 0) 121 | currencies = self.command.get_currency_list() 122 | info = self.command.get_rates_info(self.command.get_service_url(), currencies) 123 | usd_currency, _ = Currency.objects.update_or_create(code='USD', defaults={'code': 'USD'}) 124 | res = self.command.create_or_update_usd_currency_rates(info, usd_currency) 125 | self.assertIsInstance(res, dict) 126 | for c in currencies: 127 | self.assertTrue(c in res, '"{}" not in dictionary'.format(c)) 128 | self.assertIsInstance(res.get(c), Decimal) 129 | 130 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 131 | def test_create_or_update_inverted_currency_rates_permutations_creates_expected_records(self): 132 | self.assertEqual(CurrencyRate.objects.count(), 0) 133 | currency_types = self.command.get_currency_list() 134 | info = self.command.get_rates_info(self.command.get_service_url(), currency_types) 135 | usd_currency, _ = Currency.objects.update_or_create(code='USD', defaults={'code': 'USD'}) 136 | currencies = self.command.create_or_update_currency_objects(currency_types) 137 | usd_rates = self.command.create_or_update_usd_currency_rates(info, usd_currency) 138 | self.command.create_or_update_inverted_usd_currency_rates(currencies, usd_rates) 139 | self.command.create_or_update_inverted_currency_rates_permutations(currencies, currency_types, usd_rates) 140 | expected_records = len([p for p in product(currency_types, repeat=2)]) 141 | self.assertEqual(CurrencyRate.objects.count(), expected_records) 142 | 143 | @override_settings(EASY_CURRENCIES=VALID_SETTINGS) 144 | def test_created_rates_have_rate_1_if_source_and_target_currency_are_equal(self): 145 | currency_types = self.command.get_currency_list() 146 | info = self.command.get_rates_info(self.command.get_service_url(), currency_types) 147 | usd_currency, _ = Currency.objects.update_or_create(code='USD', defaults={'code': 'USD'}) 148 | currencies = self.command.create_or_update_currency_objects(currency_types) 149 | usd_rates = self.command.create_or_update_usd_currency_rates(info, usd_currency) 150 | self.command.create_or_update_inverted_usd_currency_rates(currencies, usd_rates) 151 | self.command.create_or_update_inverted_currency_rates_permutations(currencies, currency_types, usd_rates) 152 | for rate in CurrencyRate.objects.select_related().all(): 153 | if rate.original_currency.code == rate.target_currency: 154 | self.assertEqual(rate.rate, 1) 155 | -------------------------------------------------------------------------------- /django_easy_currencies/tests/TestCurrenciesTemplateTags.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from decimal import Decimal 3 | 4 | from django.test.testcases import TestCase 5 | 6 | from django.template import Template, Context 7 | 8 | from babel.numbers import format_currency 9 | 10 | 11 | class TestCurrenciesTemplateTags(TestCase): 12 | def test_local_currency_returns_same_value_if_currency_rates_have_not_been_loaded(self): 13 | template = Template( 14 | '{% load currencies %}' 15 | '{% local_currency original_price original_currency %}' 16 | ) 17 | price = Decimal('59.90') 18 | context = Context({ 19 | 'original_price': price, 20 | 'original_currency': 'USD', 21 | 'active_currency': 'EUR', 22 | 'currency_rates': {} 23 | }) 24 | self.assertEqual(template.render(context), format_currency(price, 'USD')) 25 | 26 | def test_local_currency_converts_original_price(self): 27 | template = Template( 28 | '{% load currencies %}' 29 | '{% local_currency original_price original_currency False %}' 30 | ) 31 | price = Decimal('59.90') 32 | rate = Decimal('1.277421448') 33 | context = Context({ 34 | 'original_price': price, 35 | 'original_currency': 'USD', 36 | 'active_currency': 'EUR', 37 | 'currency_rates': {'USD': rate} 38 | }) 39 | output = template.render(context).strip() 40 | self.assertEqual(output, str(price / rate)) 41 | self.assertLess(Decimal(output), price) 42 | 43 | def test_local_currency_formats_currency_with_expected_symbol(self): 44 | template = Template( 45 | '{% load currencies %}' 46 | '{% local_currency original_price original_currency %}' 47 | ) 48 | context = Context({ 49 | 'original_price': Decimal('59.90'), 50 | 'original_currency': 'USD', 51 | 'active_currency': 'EUR', 52 | 'currency_rates': {'USD': Decimal('1.277421448')} 53 | }) 54 | output = template.render(context).strip() 55 | self.assertTrue(output.startswith('\u20AC')) 56 | -------------------------------------------------------------------------------- /django_easy_currencies/tests/TestCurrencyContextProcessor.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.test.testcases import TestCase 3 | 4 | 5 | class TestCurrencyContextProcessor(TestCase): 6 | def test_processor_sets_expected_variables_in_context(self): 7 | response = self.client.get('/') 8 | self.assertEqual(response.context['active_currency'], 'USD') 9 | self.assertIsInstance(response.context['currency_rates'], dict) 10 | -------------------------------------------------------------------------------- /django_easy_currencies/tests/TestCurrencyConverter.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from decimal import Decimal 3 | import os 4 | 5 | from django.test.testcases import TestCase 6 | from django.core.management import call_command 7 | from django.test.utils import override_settings 8 | 9 | from django_easy_currencies.utils import CurrencyConverter, CurrencyConverterException 10 | 11 | 12 | SETTINGS = { 13 | 'app_id': os.environ['EASY_CURRENCIES_APP_ID'], 14 | 'currencies': ( 15 | ('USD', 'Dollars'), 16 | ('EUR', 'Euro'), 17 | ('GBP', 'Pounds'), 18 | ) 19 | } 20 | 21 | 22 | class TestCurrencyConverter(TestCase): 23 | def test_convert_raise_exception_if_rates_have_not_been_loaded(self): 24 | converter = CurrencyConverter('EUR') 25 | 26 | def bad(): 27 | converter.convert(Decimal('49.9'), 'GBP') 28 | 29 | self.assertRaises(CurrencyConverterException, bad) 30 | 31 | @override_settings(EASY_CURRENCIES=SETTINGS) 32 | def test_convert_returns_same_value_if_target_and_source_currency_are_the_same(self): 33 | call_command('currencies', update=True) 34 | original_value = Decimal('49.9') 35 | converted_value = CurrencyConverter('USD').convert(original_value, 'USD') 36 | self.assertEqual(converted_value, original_value) 37 | converted_value = CurrencyConverter('EUR').convert(original_value, 'EUR') 38 | self.assertEqual(converted_value, original_value) 39 | converted_value = CurrencyConverter('GBP').convert(original_value, 'GBP') 40 | self.assertEqual(converted_value, original_value) 41 | 42 | @override_settings(EASY_CURRENCIES=SETTINGS) 43 | def test_convert_returns_higher_value_if_original_currency_is_stronger_than_target_one(self): 44 | call_command('currencies', update=True) 45 | original_value = Decimal('49.9') 46 | converted_value = CurrencyConverter('USD').convert(original_value, 'GBP') 47 | self.assertGreater(converted_value, original_value) 48 | converted_value = CurrencyConverter('USD').convert(original_value, 'EUR') 49 | self.assertGreater(converted_value, original_value) 50 | converted_value = CurrencyConverter('EUR').convert(original_value, 'GBP') 51 | self.assertGreater(converted_value, original_value) 52 | 53 | @override_settings(EASY_CURRENCIES=SETTINGS) 54 | def test_convert_returns_lower_value_if_original_currency_is_weaker_than_target_one(self): 55 | call_command('currencies', update=True) 56 | original_value = Decimal('49.9') 57 | converted_value = CurrencyConverter('GBP').convert(original_value, 'USD') 58 | self.assertLess(converted_value, original_value) 59 | converted_value = CurrencyConverter('GBP').convert(original_value, 'EUR') 60 | self.assertLess(converted_value, original_value) 61 | converted_value = CurrencyConverter('EUR').convert(original_value, 'USD') 62 | self.assertLess(converted_value, original_value) 63 | -------------------------------------------------------------------------------- /django_easy_currencies/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daveoncode/django-easy-currencies/1da1d8e3f411c0c2aa10aee29069988ac40f7bae/django_easy_currencies/tests/__init__.py -------------------------------------------------------------------------------- /django_easy_currencies/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django_easy_currencies.views.ChangeCurrencyView import ChangeCurrencyView 3 | from django.conf.urls import patterns, url 4 | 5 | urlpatterns = patterns( 6 | '', 7 | url( 8 | regex=r'^change/$', 9 | view=ChangeCurrencyView.as_view(), 10 | name='change_currency' 11 | ), 12 | ) 13 | -------------------------------------------------------------------------------- /django_easy_currencies/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from decimal import Decimal 3 | 4 | from django_easy_currencies.models.CurrencyRate import CurrencyRate 5 | 6 | 7 | class CurrencyConverterException(Exception): 8 | pass 9 | 10 | 11 | class CurrencyConverter(object): 12 | def __init__(self, target_currency): 13 | self.target_currency = target_currency 14 | self.currency_rates = CurrencyRate.objects.get_rate_values(target_currency) 15 | 16 | def convert(self, amount, current_currency): 17 | """ 18 | Return the converted value of the given amount into the target currency. 19 | 20 | :param amount: Amount to convert. 21 | :param current_currency: Currency of the given amount. 22 | :return: :rtype: 23 | """ 24 | if not isinstance(amount, Decimal): 25 | amount = Decimal(str(amount)) 26 | try: 27 | return amount / self.currency_rates[current_currency] 28 | except KeyError: 29 | raise CurrencyConverterException( 30 | 'Unable to convert from "{}" to "{}", unavailable info. ' 31 | 'Have you run "currencies --update" command?'.format(current_currency, self.target_currency) 32 | ) 33 | -------------------------------------------------------------------------------- /django_easy_currencies/views/ChangeCurrencyView.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.utils.decorators import method_decorator 4 | from django.views.decorators.http import require_POST 5 | from django.views.generic.base import View 6 | 7 | from django.http.response import HttpResponseRedirect 8 | 9 | 10 | class ChangeCurrencyView(View): 11 | @method_decorator(require_POST) 12 | def dispatch(self, request, *args, **kwargs): 13 | """ 14 | Sets currency in session then redirects to the previous page. 15 | 16 | :param request: 17 | :param args: 18 | :param kwargs: 19 | :return: :rtype: 20 | """ 21 | request.session['currency'] = request.POST.get('currency', 'USD') 22 | origin = self.request.META.get('HTTP_REFERER', '/') 23 | return HttpResponseRedirect(origin) 24 | -------------------------------------------------------------------------------- /django_easy_currencies/views/__init__.py: -------------------------------------------------------------------------------- 1 | from ChangeCurrencyView import ChangeCurrencyView 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | 5 | 6 | # allow setup.py to be run from any path 7 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 8 | 9 | setup( 10 | name='django-easy-currencies', 11 | version='0.2.1', 12 | packages=['django_easy_currencies'], 13 | install_requires=['django', 'Babel>=1.3'], 14 | include_package_data=True, 15 | license='MIT License', 16 | description='Simple app to manage currencies conversion in Django using openexchangerates.org service.', 17 | url='https://github.com/daveoncode/django-easy-currencies', 18 | author='Davide Zanotti', 19 | author_email='davidezanotti@gmail.com', 20 | classifiers=[ 21 | 'Environment :: Web Environment', 22 | 'Framework :: Django', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.2', 30 | 'Programming Language :: Python :: 3.3', 31 | 'Topic :: Internet :: WWW/HTTP', 32 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 33 | ], 34 | ) 35 | --------------------------------------------------------------------------------