Skip to content

Commit

Permalink
TA#72095 [16.0][MIG] currency_rate_update_boc (#225)
Browse files Browse the repository at this point in the history
  • Loading branch information
lanto-razafindrabe authored Feb 5, 2025
1 parent f40eb58 commit f9f73e9
Show file tree
Hide file tree
Showing 14 changed files with 345 additions and 0 deletions.
1 change: 1 addition & 0 deletions .docker_files/main/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"attachment_minio",
"base_extended_security",
"crm", # module added for test purpose
"currency_rate_update_boc",
"database_bi_user",
"lang_fr_activated",
"mail_notification_no_action_button",
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ COPY admin_light_user /mnt/extra-addons/admin_light_user
COPY admin_light_web /mnt/extra-addons/admin_light_web
COPY attachment_minio /mnt/extra-addons/attachment_minio
COPY base_extended_security /mnt/extra-addons/base_extended_security
COPY currency_rate_update_boc /mnt/extra-addons/currency_rate_update_boc
COPY database_bi_user /mnt/extra-addons/database_bi_user
COPY lang_fr_activated /mnt/extra-addons/lang_fr_activated
COPY mail_notification_no_action_button /mnt/extra-addons/mail_notification_no_action_button
Expand Down
27 changes: 27 additions & 0 deletions currency_rate_update_boc/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Currency Rate Update Bank of Canada
===================================
This module adds the Bank of Canada to the exchange rate providers.

.. contents:: Table of Contents

Usage
-----
You can create an exchange rate provider using Bank of Canada's data.

.. image:: https://raw.githubusercontent.com/Numigi/odoo-base-addons/16.0/currency_rate_update_boc/static/description/exchange.png

You can then update the exchange rates using Bank of Canada's data.

.. image:: https://raw.githubusercontent.com/Numigi/odoo-base-addons/16.0/currency_rate_update_boc/static/description/update.png

Configuration
-------------
No configuration is required after installation.

Contributors
------------
* Numigi (tm) and all its contributors (https://bit.ly/numigiens)

More information
----------------
* Meet us at https://bit.ly/numigi-com
4 changes: 4 additions & 0 deletions currency_rate_update_boc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import models
17 changes: 17 additions & 0 deletions currency_rate_update_boc/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

{
"name": "Currency Rate Update Bank of Canada",
"version": "16.0.1.0.0",
"author": "Numigi",
"maintainer": "Numigi",
"license": "AGPL-3",
"category": "Financial Management/Configuration",
"summary": "Update exchange rates using Bank of canada",
"depends": [
"currency_rate_update",
],
"data": [],
"installable": True,
}
4 changes: 4 additions & 0 deletions currency_rate_update_boc/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import res_currency_rate_provider
34 changes: 34 additions & 0 deletions currency_rate_update_boc/models/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

SUPPORTED_CURRENCIES_BOC = [
"AUD",
"BRL",
"CAD",
"CHF",
"CNY",
"EUR",
"GBP",
"HKD",
"IDR",
"INR",
"JPY",
"KRW",
"MXN",
"MYR",
"NOK",
"NZD",
"PEN",
"RUB",
"SAR",
"SEK",
"SGD",
"THB",
"TRY",
"TWD",
"USD",
"VND",
"ZAR",
]

BASE_API_ADDRESS = "https://www.bankofcanada.ca/valet/observations/"
98 changes: 98 additions & 0 deletions currency_rate_update_boc/models/res_currency_rate_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).


from collections import defaultdict
from datetime import timedelta
import requests

from odoo import fields, models, _
from odoo.exceptions import ValidationError

from .currency import SUPPORTED_CURRENCIES_BOC, BASE_API_ADDRESS


class ResCurrencyRateProvider(models.Model):
_inherit = "res.currency.rate.provider"

service = fields.Selection(
selection_add=[("bank_of_canada", "Bank of canada")],
ondelete={"bank_of_canada": "set default"},
)

def _get_supported_currencies(self):
self.ensure_one()
if self.service != "bank_of_canada":
return super()._get_supported_currencies()
return SUPPORTED_CURRENCIES_BOC

def _obtain_rates(self, base_currency, currencies, date_from, date_to):
self.ensure_one()
if self.service != "bank_of_canada":
return super()._obtain_rates(base_currency, currencies, date_from, date_to)

date_from = date_from - timedelta(1)
observations = self._get_rates_from_boc(
base_currency, currencies, date_from, date_to
)["observations"]
return self._process_observations(observations, base_currency, currencies)

def _get_rates_from_boc(self, base_currency, currencies, date_from, date_to):
url = self._get_boc_url(base_currency, currencies, date_from, date_to)
response = self._get_boc_response(url)
return response.json()

def _get_boc_url(self, base_currency, currencies, date_from, date_to):
date_from = date_from.strftime("%Y-%m-%d")
date_to = date_to.strftime("%Y-%m-%d")
exchanges = {s for s in self._iter_boc_series(base_currency, currencies)}
date_filter = f"start_date={date_from}&end_date={date_to}"
return f"{BASE_API_ADDRESS}{','.join(exchanges)}?{date_filter}"

def _iter_boc_series(self, base_currency, currencies):
for currency in sorted(currencies):
if base_currency == "CAD" or currency == "CAD":
yield f"FX{base_currency}{currency}"
else:
yield f"FX{base_currency}CAD"
yield f"FXCAD{currency}"

def _get_boc_response(self, url):
response = requests.get(url)
if response.status_code >= 400:
raise ValidationError(
_(
"The request to the Valet api of the Bank of Canada terminated"
"with an error.\n\n{} : {}".format(response.text, url)
)
)

return response

def _process_observations(self, observations, base_currency, currencies):
result = defaultdict(dict)
for observation in observations:
for currency in currencies:
result[observation["d"]][currency] = round(
self._get_rate_from_boc_observation(
observation, base_currency, currency
),
4,
)

return result

def _get_rate_from_boc_observation(self, observation, base_currency, currency):
if base_currency == "CAD" or currency == "CAD":
series = f"FX{base_currency}{currency}"
return self._get_rate_from_boc_series(observation, series)
else:
first_series = f"FX{base_currency}CAD"
second_series = f"FXCAD{currency}"
first_rate = self._get_rate_from_boc_series(observation, first_series)
second_rate = self._get_rate_from_boc_series(observation, second_series)

return first_rate * second_rate

def _get_rate_from_boc_series(self, observation, series):
return float(observation[series]["v"])
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions currency_rate_update_boc/tests/FX_RATES.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"terms": {
"url": "https://www.bankofcanada.ca/terms/"
},
"seriesDetail": {
"FXEURCAD": {
"label": "EUR/CAD",
"description": "European euro to Canadian dollar daily exchange rate",
"dimension": {
"key": "d",
"name": "Date"
}
},
"FXUSDCAD": {
"label": "USD/CAD",
"description": "US dollar to Canadian dollar daily exchange rate",
"dimension": {
"key": "d",
"name": "Date"
}
}
},
"observations": [
{
"d": "2021-05-03",
"FXEURCAD": {
"v": "1.4808"
},
"FXUSDCAD": {
"v": "1.2279"
}
}
]
}
4 changes: 4 additions & 0 deletions currency_rate_update_boc/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import test_currency
121 changes: 121 additions & 0 deletions currency_rate_update_boc/tests/test_currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Copyright 2023 Numigi (tm) and all its contributors (https://bit.ly/numigiens)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from contextlib import contextmanager
from datetime import date, timedelta
from ddt import ddt, data
import json
import os
import pytest

from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError
from odoo.addons.website.tools import MockRequest


@ddt
class TestBocRateProvider(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.cad = cls.env.ref("base.CAD")
cls.cad.active = True

cls.usd = cls.env.ref("base.USD")

cls.company = cls.env["res.company"].create({"name": "Company"})
cls.company.currency_id = cls.usd

cls.date = date(2021, 5, 3)
cls.date_string = cls.date.strftime("%Y-%m-%d")

cls.provider = cls.env["res.currency.rate.provider"].create(
{
"service": "bank_of_canada",
"currency_ids": [(4, cls.cad.id)],
"company_id": cls.company.id,
}
)

cls.test_boc_url = (
"https://www.bankofcanada.ca/valet/observations/"
"FXUSDCAD,FXEURCAD?start_date=%s&end_date=%s"
) % (cls.date_string, cls.date_string)
cls.dumb_test_boc_url = "https://www.bankofcanada.ca/valet/observations/"

@data("CAD", "USD", "EUR")
def test_supported_currencies(self, currency_code):
supported_currencies = self.provider.available_currency_ids.mapped("name")
assert currency_code in supported_currencies

def test_boc_response(self):
json_data = self._get_rates_json()

with self._mock_boc_response(self.test_boc_url, json_data):
rates = self.provider._get_boc_response(self.test_boc_url).json()
assert rates == json_data

@data(401, 404, 410, 500, 502)
def test_boc_response_exceptions(self, error_code):
with MockRequest(self.env) as m:
m.httprequest.method = "GET"
m.get(self.dumb_test_boc_url, status_code=error_code)
with pytest.raises(ValidationError):
self.provider._get_boc_response(self.dumb_test_boc_url)

def test_rates_cad2x(self):
rates = self.provider._obtain_rates("CAD", ["USD", "EUR"], self.date, self.date)
assert rates[self.date_string]["USD"] == round(1 / 1.2279, 4)
assert rates[self.date_string]["EUR"] == round(1 / 1.4808, 4)

def test_rates_x2cad(self):
rates = self.provider._obtain_rates("USD", ["CAD"], self.date, self.date)
assert rates[self.date_string]["CAD"] == 1.2279

def test_rates_x2x(self):
rates = self.provider._obtain_rates("USD", ["EUR"], self.date, self.date)
assert rates[self.date_string]["EUR"] == round(1.2279 * 1 / 1.4808, 4)

def test_invalid_currency(self):
with pytest.raises(ValidationError):
self.provider._obtain_rates("USD", ["ZZZZ"], self.date, self.date)

def test_invalid_date(self):
with pytest.raises(ValidationError):
self.provider._obtain_rates(
"USD", ["ZZZZ"], self.date, self.date - timedelta(5)
)

def test_cron__loads_rates_one_day_prior(self):
"""Test that the rates of yesterday are loaded by the cron.
With the Bank of Canada, the daily rates are only available the day after.
When the cron is executed the rates of the current date are not available.
The rates of the current date are loaded the following day.
Otherwise, the cron ends up loading no rate at all.
"""
self.provider.last_successful_run = self.date
self.provider.next_run = self.date + timedelta(1)

url = self.test_boc_url + "FXUSDCAD"
json_data = self._get_rates_json()

with self._mock_boc_response(url, json_data):
self.env["res.currency.rate.provider"]._scheduled_update()

rate = self.cad._get_rates(self.company, self.date)[self.cad.id]
assert round(rate, 4) == 1.2279

@contextmanager
def _mock_boc_response(self, url, json_data):
with MockRequest(self.env) as m:
m.httprequest.method = "GET"
m.get(url, json=json_data)
yield

def _get_rates_json(self):
dir_path = os.path.dirname(os.path.realpath(__file__))
with open(f"{dir_path}/FX_RATES.json") as f:
data = json.load(f)
return data

0 comments on commit f9f73e9

Please sign in to comment.