Files
tryton/modules/currency/currency.py
T

473 lines
15 KiB
Python
Raw Normal View History

2014-12-02 12:55:17 +01:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
2010-08-10 11:58:58 -04:00
import datetime
2022-12-25 18:54:30 +01:00
import decimal
2021-02-21 19:31:10 +01:00
import logging
2022-12-25 18:54:30 +01:00
from decimal import Decimal, localcontext
2017-11-25 22:17:02 +01:00
2021-02-21 19:31:10 +01:00
from dateutil.relativedelta import relativedelta
2017-12-26 17:31:44 +01:00
from sql import Window
from sql.functions import NthValue
2018-12-29 14:20:29 +01:00
from trytond.i18n import gettext
2018-03-26 14:10:21 +02:00
from trytond.model import (
Check, DeactivableMixin, DigitsMixin, Index, ModelSQL, ModelView,
SymbolMixin, Unique, fields)
2011-07-03 13:18:55 +02:00
from trytond.pool import Pool
2021-02-21 19:31:10 +01:00
from trytond.pyson import Eval, If
2021-12-11 17:58:42 +01:00
from trytond.rpc import RPC
from trytond.transaction import Transaction
2012-09-24 22:17:53 +02:00
from .ecb import RatesNotAvailableError, get_rates
2018-12-29 14:20:29 +01:00
from .exceptions import RateError
2021-04-13 01:47:05 +02:00
from .ir import rate_decimal
2018-12-29 14:20:29 +01:00
2021-02-21 19:31:10 +01:00
logger = logging.getLogger(__name__)
2008-05-09 17:26:14 +02:00
2022-12-25 18:54:30 +01:00
ROUNDING_OPPOSITES = {
decimal.ROUND_HALF_EVEN: decimal.ROUND_HALF_EVEN,
decimal.ROUND_HALF_UP: decimal.ROUND_HALF_DOWN,
decimal.ROUND_HALF_DOWN: decimal.ROUND_HALF_UP,
decimal.ROUND_UP: decimal.ROUND_DOWN,
decimal.ROUND_DOWN: decimal.ROUND_UP,
decimal.ROUND_CEILING: decimal.ROUND_FLOOR,
decimal.ROUND_FLOOR: decimal.ROUND_CEILING,
}
2021-08-21 09:09:55 +02:00
class Currency(
SymbolMixin, DigitsMixin, DeactivableMixin, ModelSQL, ModelView):
2008-05-09 17:26:14 +02:00
'Currency'
2012-09-24 22:17:53 +02:00
__name__ = 'currency.currency'
2018-01-31 15:40:36 +01:00
name = fields.Char('Name', required=True, translate=True,
help="The main identifier of the currency.")
symbol = fields.Char(
2023-02-23 13:39:32 +01:00
"Symbol", size=10, strip=False,
2018-01-31 15:40:36 +01:00
help="The symbol used for currency formating.")
code = fields.Char('Code', size=3, required=True,
help="The 3 chars ISO currency code.")
numeric_code = fields.Char('Numeric Code', size=3,
help="The 3 digits ISO currency code.")
2021-04-13 01:47:05 +02:00
rate = fields.Function(fields.Numeric(
"Current rate", digits=(rate_decimal * 2, rate_decimal)),
2014-01-26 14:29:11 +01:00
'get_rate')
2018-01-31 15:40:36 +01:00
rates = fields.One2Many('currency.currency.rate', 'currency', 'Rates',
help="Add floating exchange rates for the currency.")
2018-01-17 10:52:03 +01:00
rounding = fields.Numeric('Rounding factor', required=True,
digits=(12, Eval('digits', 6)),
2018-01-31 15:40:36 +01:00
help="The minimum amount which can be represented in this currency.")
digits = fields.Integer("Digits", required=True,
help="The number of digits to display after the decimal separator.")
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
@classmethod
def __setup__(cls):
super(Currency, cls).__setup__()
cls._order.insert(0, ('code', 'ASC'))
cls.__rpc__.update({
2018-04-05 19:28:12 +02:00
'compute': RPC(instantiate=slice(0, 3, 2)),
2012-09-24 22:17:53 +02:00
})
2008-05-09 17:26:14 +02:00
2017-11-25 22:17:02 +01:00
@classmethod
def __register__(cls, module_name):
2019-07-30 23:02:46 +02:00
pool = Pool()
Data = pool.get('ir.model.data')
data = Data.__table__()
cursor = Transaction().connection.cursor()
2017-11-25 22:17:02 +01:00
super(Currency, cls).__register__(module_name)
2018-07-24 22:01:07 +02:00
table_h = cls.__table_handler__(module_name)
2017-11-25 22:17:02 +01:00
2019-07-30 23:02:46 +02:00
# Migration from 5.2: remove country data
cursor.execute(*data.delete(where=(data.module == 'currency')
& (data.model == cls.__name__)))
2023-02-23 13:39:32 +01:00
# Migration from 6.6: remove required on symbol
table_h.not_null_action('symbol', 'remove')
2012-09-24 22:17:53 +02:00
@staticmethod
def default_rounding():
return Decimal('0.01')
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
@staticmethod
def default_digits():
2008-05-09 17:26:14 +02:00
return 2
2012-09-24 22:17:53 +02:00
@classmethod
def check_xml_record(cls, records, values):
pass
2009-04-02 20:00:11 +02:00
2014-02-20 23:27:52 +01:00
@classmethod
def search_global(cls, text):
2015-02-04 13:38:09 +01:00
for record, rec_name, icon in super(Currency, cls).search_global(text):
2014-02-20 23:27:52 +01:00
icon = icon or 'tryton-currency'
2015-02-04 13:38:09 +01:00
yield record, rec_name, icon
2014-02-20 23:27:52 +01:00
2012-09-24 22:17:53 +02:00
@classmethod
def search_rec_name(cls, name, clause):
currencies = None
field = None
for field in ('code', 'numeric_code'):
currencies = cls.search([(field,) + tuple(clause[1:])], limit=1)
2012-09-24 22:17:53 +02:00
if currencies:
break
2012-09-24 22:17:53 +02:00
if currencies:
return [(field,) + tuple(clause[1:])]
return [(cls._rec_name,) + tuple(clause[1:])]
2008-05-09 17:26:14 +02:00
2014-01-26 14:29:11 +01:00
@fields.depends('rates')
2012-09-24 22:17:53 +02:00
def on_change_with_rate(self):
now = datetime.date.today()
closer = datetime.date.min
res = Decimal(0)
2012-09-24 22:17:53 +02:00
for rate in self.rates or []:
date = getattr(rate, 'date', None) or now
if date <= now and date > closer:
res = rate.rate
closer = date
return res
2012-09-24 22:17:53 +02:00
@staticmethod
def get_rate(currencies, name):
2008-05-09 17:26:14 +02:00
'''
Return the rate at the date from the context or the current date
'''
2012-09-24 22:17:53 +02:00
Rate = Pool().get('currency.currency.rate')
Date = Pool().get('ir.date')
res = {}
2012-09-24 22:17:53 +02:00
date = Transaction().context.get('date', Date.today())
for currency in currencies:
rates = Rate.search([
('currency', '=', currency.id),
('date', '<=', date),
], limit=1, order=[('date', 'DESC')])
if rates:
res[currency.id] = rates[0].id
2008-05-09 17:26:14 +02:00
else:
res[currency.id] = 0
2008-05-09 17:26:14 +02:00
rate_ids = [x for x in res.values() if x]
2012-09-24 22:17:53 +02:00
rates = Rate.browse(rate_ids)
2008-05-09 17:26:14 +02:00
id2rate = {}
for rate in rates:
id2rate[rate.id] = rate
for currency_id in res.keys():
if res[currency_id]:
res[currency_id] = id2rate[res[currency_id]].rate
return res
2022-12-25 18:54:30 +01:00
def round(self, amount, rounding=decimal.ROUND_HALF_EVEN, opposite=False):
2014-01-13 16:37:31 +01:00
'Round the amount depending of the currency'
2022-12-25 18:54:30 +01:00
if opposite:
rounding = ROUNDING_OPPOSITES[rounding]
2020-03-21 16:08:12 +01:00
return self._round(amount, self.rounding, rounding)
@classmethod
def _round(cls, amount, factor, rounding):
2021-04-16 18:59:08 +02:00
if not factor:
return amount
with localcontext() as ctx:
2020-03-21 16:08:12 +01:00
ctx.prec = max(ctx.prec, (amount / factor).adjusted() + 1)
# Divide and multiple by factor for case factor is not 10En
result = (amount / factor).quantize(Decimal('1.'),
rounding=rounding) * factor
return Decimal(result)
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
def is_zero(self, amount):
2008-05-09 17:26:14 +02:00
'Return True if the amount can be considered as zero for the currency'
2021-04-16 18:59:08 +02:00
if not self.rounding:
return not amount
return abs(self.round(amount)) < abs(self.rounding)
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
@classmethod
def compute(cls, from_currency, amount, to_currency, round=True):
2008-05-09 17:26:14 +02:00
'''
Take a currency and an amount
Return the amount to the new currency
Use the rate of the date of the context or the current date
2008-05-09 17:26:14 +02:00
'''
2012-09-24 22:17:53 +02:00
Date = Pool().get('ir.date')
Lang = Pool().get('ir.lang')
2018-04-05 19:28:12 +02:00
from_currency = cls(int(from_currency))
to_currency = cls(int(to_currency))
if to_currency == from_currency:
if round:
2012-09-24 22:17:53 +02:00
return to_currency.round(amount)
else:
return amount
2008-05-09 17:26:14 +02:00
if (not from_currency.rate) or (not to_currency.rate):
2012-09-24 22:17:53 +02:00
date = Transaction().context.get('date', Date.today())
2008-05-09 17:26:14 +02:00
if not from_currency.rate:
name = from_currency.name
else:
name = to_currency.name
2018-10-31 18:15:57 +01:00
lang = Lang.get()
2018-12-29 14:20:29 +01:00
raise RateError(gettext('currency.msg_no_rate',
currency=name,
date=lang.strftime(date)))
if round:
2012-09-24 22:17:53 +02:00
return to_currency.round(
amount * to_currency.rate / from_currency.rate)
2008-05-09 17:26:14 +02:00
else:
return amount * to_currency.rate / from_currency.rate
2017-12-26 17:31:44 +01:00
@classmethod
def currency_rate_sql(cls):
"Return a SQL query with currency, rate, start_date and end_date"
pool = Pool()
Rate = pool.get('currency.currency.rate')
transaction = Transaction()
database = transaction.database
rate = Rate.__table__()
if database.has_window_functions():
window = Window(
[rate.currency],
order_by=[rate.date.asc],
frame='ROWS', start=0, end=1)
# Use NthValue instead of LastValue to get NULL for the last row
end_date = NthValue(rate.date, 2, window=window)
else:
next_rate = Rate.__table__()
end_date = next_rate.select(
next_rate.date,
where=(next_rate.currency == rate.currency)
& (next_rate.date > rate.date),
order_by=[next_rate.date.asc],
limit=1)
query = (rate
.select(
rate.currency.as_('currency'),
rate.rate.as_('rate'),
rate.date.as_('start_date'),
end_date.as_('end_date'),
))
return query
2008-05-09 17:26:14 +02:00
2020-10-09 19:09:05 +02:00
def get_symbol(self, sign, symbol=None):
pool = Pool()
Lang = pool.get('ir.lang')
lang = Lang.get()
symbol, position = super().get_symbol(sign, symbol=symbol)
2023-02-23 13:39:32 +01:00
if not symbol:
symbol = self.code
2020-10-09 19:09:05 +02:00
if ((sign < 0 and lang.n_cs_precedes)
or (sign >= 0 and lang.p_cs_precedes)):
position = 0
return symbol, position
2008-05-09 17:26:14 +02:00
2020-03-01 12:33:52 +01:00
class CurrencyRate(ModelSQL, ModelView):
"Currency Rate"
2012-09-24 22:17:53 +02:00
__name__ = 'currency.currency.rate'
date = fields.Date(
"Date", required=True,
2018-01-31 15:40:36 +01:00
help="From when the rate applies.")
rate = fields.Numeric(
"Rate", digits=(rate_decimal * 2, rate_decimal), required=1,
2018-01-31 15:40:36 +01:00
help="The floating exchange rate used to convert the currency.")
currency = fields.Many2One('currency.currency', 'Currency',
2018-01-31 15:40:36 +01:00
ondelete='CASCADE',
help="The currency on which the rate applies.")
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
@classmethod
def __setup__(cls):
2020-03-01 12:33:52 +01:00
super().__setup__()
2021-03-07 11:13:53 +01:00
cls.__access__.add('currency')
t = cls.__table__()
2012-09-24 22:17:53 +02:00
cls._sql_constraints = [
('date_currency_uniq', Unique(t, t.date, t.currency),
2018-12-29 14:20:29 +01:00
'currency.msg_currency_unique_rate_date'),
('check_currency_rate', Check(t, t.rate >= 0),
2018-12-29 14:20:29 +01:00
'currency.msg_currency_rate_positive'),
2012-09-24 22:17:53 +02:00
]
cls._sql_indexes.add(
Index(
t,
(t.currency, Index.Equality()),
(t.date, Index.Range()),
order='DESC'))
2012-09-24 22:17:53 +02:00
cls._order.insert(0, ('date', 'DESC'))
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
@staticmethod
def default_date():
Date = Pool().get('ir.date')
return Date.today()
2008-05-09 17:26:14 +02:00
2012-09-24 22:17:53 +02:00
@classmethod
2018-09-19 00:15:58 +02:00
def check_xml_record(cls, records, values):
pass
2017-07-26 23:43:21 +02:00
def get_rec_name(self, name):
return str(self.date)
2021-02-21 19:31:10 +01:00
class CronFetchError(Exception):
pass
class Cron(ModelSQL, ModelView):
"Currency Cron"
__name__ = 'currency.cron'
source = fields.Selection(
[('ecb', "European Central Bank")],
"Source", required=True,
2021-02-21 19:31:10 +01:00
help="The external source for rates.")
frequency = fields.Selection([
('daily', "Daily"),
('weekly', "Weekly"),
('monthly', "Monthly"),
], "Frequency", required=True,
help="How frequently rates must be updated.")
weekday = fields.Many2One(
'ir.calendar.day', "Day of Week",
states={
'required': Eval('frequency') == 'weekly',
'invisible': Eval('frequency') != 'weekly',
})
2021-02-21 19:31:10 +01:00
day = fields.Integer(
"Day of Month",
domain=[If(Eval('frequency') == 'monthly',
[('day', '>=', 1), ('day', '<=', 31)],
[('day', '=', None)]),
],
states={
'required': Eval('frequency') == 'monthly',
'invisible': Eval('frequency') != 'monthly',
})
2021-02-21 19:31:10 +01:00
currency = fields.Many2One(
'currency.currency', "Currency", required=True,
help="The base currency to fetch rate.")
currencies = fields.Many2Many(
'currency.cron-currency.currency', 'cron', 'currency', "Currencies",
help="The currencies to update the rate.")
last_update = fields.Date("Last Update", required=True)
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'run': {},
})
@classmethod
def default_frequency(cls):
return 'monthly'
@classmethod
def default_day(cls):
return 1
@classmethod
def default_last_update(cls):
pool = Pool()
Date = pool.get('ir.date')
return Date.today()
@classmethod
@ModelView.button
def run(cls, crons):
cls.update(crons)
@classmethod
def update(cls, crons=None):
pool = Pool()
Rate = pool.get('currency.currency.rate')
if crons is None:
crons = cls.search([])
rates = []
for cron in crons:
rates.extend(cron._update())
Rate.save(rates)
cls.save(crons)
def _update(self):
limit = self.limit_update()
date = self.next_update()
while date <= limit:
try:
yield from self._rates(date)
except CronFetchError:
logger.warning("Could not fetch rates temporary")
2021-06-17 09:24:19 +02:00
if date >= datetime.date.today():
break
2021-02-21 19:31:10 +01:00
except Exception:
logger.error("Fail to fetch rates", exc_info=True)
break
self.last_update = date
date = self.next_update()
def next_update(self):
return self.last_update + self.delta()
def limit_update(self):
pool = Pool()
Date = pool.get('ir.date')
return Date.today()
def delta(self):
if self.frequency == 'daily':
delta = relativedelta(days=1)
elif self.frequency == 'weekly':
delta = relativedelta(weeks=1, weekday=int(self.weekday.index))
elif self.frequency == 'monthly':
delta = relativedelta(months=1, day=self.day)
else:
delta = relativedelta()
return delta
def _rates(self, date, rounding=None):
pool = Pool()
Rate = pool.get('currency.currency.rate')
values = getattr(self, 'fetch_%s' % self.source)(date)
exp = Decimal(Decimal(1) / 10 ** Rate.rate.digits[1])
rates = Rate.search([
('date', '=', date),
])
code2rates = {r.currency.code: r for r in rates}
def get_rate(currency):
if currency.code in code2rates:
rate = code2rates[currency.code]
else:
rate = Rate(date=date, currency=currency)
return rate
rate = get_rate(self.currency)
rate.rate = Decimal(1).quantize(exp, rounding=rounding)
yield rate
for currency in self.currencies:
if currency.code not in values:
continue
value = values[currency.code]
2021-06-12 10:06:03 +02:00
if not isinstance(value, Decimal):
value = Decimal(value)
2021-02-21 19:31:10 +01:00
rate = get_rate(currency)
rate.rate = value.quantize(exp, rounding=rounding)
yield rate
def fetch_ecb(self, date):
try:
return get_rates(self.currency.code, date)
except RatesNotAvailableError as e:
raise CronFetchError() from e
class Cron_Currency(ModelSQL):
"Currency Cron - Currency"
__name__ = 'currency.cron-currency.currency'
cron = fields.Many2One(
'currency.cron', "Cron",
required=True, ondelete='CASCADE')
2021-02-21 19:31:10 +01:00
currency = fields.Many2One(
'currency.currency', "Currency",
required=True, ondelete='CASCADE')