From ec2ca6d49a56c31c8d5a8facc3aa69c6f0683cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 21 Feb 2021 19:31:10 +0100 Subject: [PATCH] Add scheduled task to update rates issue8804 review347191002 --- CHANGELOG | 2 + __init__.py | 4 + currency.py | 179 +++++++++++++++++++++++- currency.xml | 56 ++++++++ doc/design.rst | 20 +++ doc/usage.rst | 26 ++++ ir.py | 13 ++ setup.py | 3 +- tests/scenario_currency_rate_update.rst | 54 +++++++ tests/test_currency.py | 5 + view/cron_form.xml | 23 +++ view/cron_list.xml | 9 ++ 12 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 ir.py create mode 100644 tests/scenario_currency_rate_update.rst create mode 100644 view/cron_form.xml create mode 100644 view/cron_list.xml diff --git a/CHANGELOG b/CHANGELOG index 3c78842b8b..4644ffed7f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Add scheduled task to update rates + Version 5.8.0 - 2020-11-02 * Bug fixes (see mercurial logs for details) * Remove support for Python 3.5 diff --git a/__init__.py b/__init__.py index b8c711ea93..e1815683ba 100644 --- a/__init__.py +++ b/__init__.py @@ -7,7 +7,11 @@ from trytond.pool import Pool def register(): # Prevent to import backend when importing scripts from . import currency + from . import ir Pool.register( currency.Currency, currency.CurrencyRate, + currency.Cron, + currency.Cron_Currency, + ir.Cron, module='currency', type_='model') diff --git a/currency.py b/currency.py index b5d9c7c00c..04919fb405 100644 --- a/currency.py +++ b/currency.py @@ -1,22 +1,33 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime +import logging from decimal import Decimal, ROUND_HALF_EVEN, localcontext +from dateutil.relativedelta import relativedelta + from sql import Window from sql.functions import NthValue +try: + from forex_python.converter import CurrencyRates, RatesNotAvailableError + get_rates = CurrencyRates(force_decimal=True).get_rates +except ImportError: + CurrencyRates = get_rates = None + from trytond.i18n import gettext from trytond.model import ( ModelView, ModelSQL, DeactivableMixin, fields, Unique, Check, SymbolMixin) from trytond.transaction import Transaction from trytond.pool import Pool from trytond.rpc import RPC -from trytond.pyson import Eval +from trytond.pyson import Eval, If from .exceptions import RateError +logger = logging.getLogger(__name__) + class Currency(SymbolMixin, DeactivableMixin, ModelSQL, ModelView): 'Currency' @@ -269,3 +280,169 @@ class CurrencyRate(ModelSQL, ModelView): def get_rec_name(self, name): return str(self.date) + + +class CronFetchError(Exception): + pass + + +class Cron(ModelSQL, ModelView): + "Currency Cron" + __name__ = 'currency.cron' + + source = fields.Selection( + [], "Source", required=True, + 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', + }, + depends=['frequency']) + 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', + }, + depends=['frequency']) + 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': {}, + }) + if CurrencyRates: + cls.source.selection.append(('ecb', "European Central Bank")) + + @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") + break + 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] + 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, select=True, ondelete='CASCADE') + currency = fields.Many2One( + 'currency.currency', "Currency", + required=True, ondelete='CASCADE') diff --git a/currency.xml b/currency.xml index 7dee3e4e72..54a04087e6 100644 --- a/currency.xml +++ b/currency.xml @@ -98,5 +98,61 @@ this repository contains the full copyright notices and license terms. --> + + currency.cron + tree + cron_list + + + + currency.cron + form + cron_form + + + + Scheduled Rate Updates + currency.cron + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + run + Run + + + + + currency.cron|update + + days + diff --git a/doc/design.rst b/doc/design.rst index 613b0afc83..f62a20a769 100644 --- a/doc/design.rst +++ b/doc/design.rst @@ -37,3 +37,23 @@ currencies. As exchange rates vary over time they are stored along with the date from when they apply. All exchange rates are relative with respect to each other. + +.. _model-currency.cron: + +Scheduled Rate Updates +====================== + +The `Exchange Rates ` can be automatically updated using a +`Scheduled Task `. +When the scheduled task is run it uses the external source to fill in the +missing rates since the last update. +The frequency determines which dates get rates. + +.. seealso:: + + Scheduled Rate Updates can be found by opening the main menu item: + + |Currency --> Scheduled Rate Updates|__ + + .. |Currency --> Scheduled Rate Updates| replace:: :menuselection:`Currency --> Scheduled Rate Updates` + __ https://demo.tryton.org/model/currency.cron diff --git a/doc/usage.rst b/doc/usage.rst index 12bc7a07ed..5faa063e14 100644 --- a/doc/usage.rst +++ b/doc/usage.rst @@ -35,3 +35,29 @@ the 15th of January you just need to set the rate on the USD currency to and 14th of January the last available rate will be used. Here this rate would be ``1.1222`` as this was the rate set for the 1st of January. + +.. _Scheduling rate updates: + +Scheduling rate updates +======================= + +You may want to define some `Scheduled Rate Updates ` to +keep the `Exchange Rates ` up to date. +When doing this you have to pick a source, the base `currency +` and a frequency. +Then for each required date since the last update, a rate of ``1.0`` will be +set for the base currency and the corresponding rate will be set for each of +the selected currencies. + +.. warning:: + + It is strongly advised to use the same base currency for all *Scheduled Rate + Updates*. + +.. note:: + + The currency module only supports the rates provided by the `European + Central Bank`_, but third party modules can add additional sources for + exchange rates. + +.. _European Central Bank: https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html diff --git a/ir.py b/ir.py new file mode 100644 index 0000000000..a2b17512ed --- /dev/null +++ b/ir.py @@ -0,0 +1,13 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +from trytond.pool import PoolMeta + + +class Cron(metaclass=PoolMeta): + __name__ = 'ir.cron' + + @classmethod + def __setup__(cls): + super().__setup__() + cls.method.selection.append( + ('currency.cron|update', "Update Currency Rates")) diff --git a/setup.py b/setup.py index 12d746e22c..0768ba5d08 100755 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ else: if local_version: version += '+' + '.'.join(local_version) -requires = ['python-sql >= 0.9'] +requires = ['python-sql >= 0.9', 'python-dateutil'] for dep in info.get('depends', []): if not re.match(r'(ir|res)(\W|$)', dep): requires.append(get_require_version('trytond_%s' % dep)) @@ -144,6 +144,7 @@ setup(name=name, extras_require={ 'data': [ 'pycountry', 'forex-python', get_require_version('proteus')], + 'ecb_rate': ['forex-python'], }, dependency_links=dependency_links, zip_safe=False, diff --git a/tests/scenario_currency_rate_update.rst b/tests/scenario_currency_rate_update.rst new file mode 100644 index 0000000000..4cf3ddfe8d --- /dev/null +++ b/tests/scenario_currency_rate_update.rst @@ -0,0 +1,54 @@ +==================== +Currency Rate Update +==================== + +Imports:: + + >>> import datetime as dt + >>> from proteus import Model + >>> from trytond.tests.tools import activate_modules + + >>> today = dt.date.today() + >>> yesterday = today - dt.timedelta(days=1) + >>> before_yesterday = yesterday - dt.timedelta(days=1) + +Activate modules:: + + >>> config = activate_modules('currency') + +Import models:: + + >>> Currency = Model.get('currency.currency') + >>> Cron = Model.get('currency.cron') + +Create some currencies:: + + >>> eur = Currency(name="Euro", code='EUR', symbol="€") + >>> eur.save() + >>> usd = Currency(name="U.S. Dollar", code='USD', symbol="$") + >>> usd.save() + +Setup cron:: + + >>> cron = Cron() + >>> cron.source = 'ecb' + >>> cron.frequency = 'daily' + >>> cron.day = None + >>> cron.currency = eur + >>> cron.currencies.append(Currency(usd.id)) + >>> cron.last_update = before_yesterday + >>> cron.save() + +Run update:: + + >>> cron.click('run') + >>> cron.last_update >= yesterday + True + + >>> eur.reload() + >>> rate, = [r for r in eur.rates if r.date == yesterday] + >>> rate.rate + Decimal('1.000000') + >>> rate, = [r for r in usd.rates if r.date == yesterday] + >>> bool(rate.rate) + True diff --git a/tests/test_currency.py b/tests/test_currency.py index ee043f8de6..c31fbcd050 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -303,4 +303,9 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', checker=doctest_checker, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite( + 'scenario_currency_rate_update.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite diff --git a/view/cron_form.xml b/view/cron_form.xml new file mode 100644 index 0000000000..e349c72b95 --- /dev/null +++ b/view/cron_form.xml @@ -0,0 +1,23 @@ + + +
+