diff --git a/currency_rate_update_coingecko/__init__.py b/currency_rate_update_coingecko/__init__.py new file mode 100644 index 0000000..4b76c7b --- /dev/null +++ b/currency_rate_update_coingecko/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/currency_rate_update_coingecko/__manifest__.py b/currency_rate_update_coingecko/__manifest__.py new file mode 100644 index 0000000..5071893 --- /dev/null +++ b/currency_rate_update_coingecko/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2023 Tecnativa - Ernesto Tejeda +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Currency Rate Update: CoinGecko", + "version": "16.0.1.0.0", + "category": "Financial Management/Configuration", + "summary": "Update exchange rates using CoinGecko", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://www.onestein.nl", + "license": "AGPL-3", + "installable": True, + "application": False, + "depends": ["currency_rate_update_mapping", "crypto_currency"], + "data": ["data/res_currency_rate_provider.xml"], + "external_dependencies": {"python": ["pycgapi"]}, +} diff --git a/currency_rate_update_coingecko/data/res_currency_rate_provider.xml b/currency_rate_update_coingecko/data/res_currency_rate_provider.xml new file mode 100644 index 0000000..9aacbd9 --- /dev/null +++ b/currency_rate_update_coingecko/data/res_currency_rate_provider.xml @@ -0,0 +1,10 @@ + + + + + CoinGecko + + diff --git a/currency_rate_update_coingecko/models/__init__.py b/currency_rate_update_coingecko/models/__init__.py new file mode 100644 index 0000000..7d87a1b --- /dev/null +++ b/currency_rate_update_coingecko/models/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import res_currency_rate_provider_CoinGecko diff --git a/currency_rate_update_coingecko/models/res_currency_rate_provider_CoinGecko.py b/currency_rate_update_coingecko/models/res_currency_rate_provider_CoinGecko.py new file mode 100644 index 0000000..5e2530d --- /dev/null +++ b/currency_rate_update_coingecko/models/res_currency_rate_provider_CoinGecko.py @@ -0,0 +1,133 @@ +# Copyright 2024 Onestein +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging +from datetime import date, timedelta + +from pycgapi import CoinGeckoAPI + +from odoo import _, fields, models + +_logger = logging.getLogger(__name__) + + +class ResCurrencyRateProviderCoinGecko(models.Model): + _inherit = "res.currency.rate.provider" + + service = fields.Selection( + selection_add=[("CoinGecko", "CoinGecko")], + ondelete={"CoinGecko": "set default"}, + ) + + def _get_supported_currencies(self): + self.ensure_one() + if self.service != "CoinGecko": + return super()._get_supported_currencies() + # List of cryptocurrencies based on configured provider mappings + supported_currencies = ( + self.env["res.currency.rate.provider.mapping"] + .search([("provider_id", "=", self.id)]) + .mapped("currency_id.name") + ) + return supported_currencies + + def _obtain_rates(self, base_currency, currencies, date_from, date_to): + self.ensure_one() + if self.service != "CoinGecko": + return super()._obtain_rates(base_currency, currencies, date_from, date_to) + if date_from < date.today(): + return self._get_historical_rate_from_coingecko( + date_from, date_to, base_currency + ) + else: + return self._get_latest_rate_from_coingecko(base_currency) + + def _get_latest_rate_from_coingecko(self, base_currency): + """Get all the exchange rates for today""" + api = CoinGeckoAPI() + today = date.today() + data = {today: {}} + for ( + currency + ) in self.currency_ids.res_currency_rate_provider_mapping_ids.filtered( + lambda l: l.provider_id == self + ): + try: + coin_data = api.coin_historical_on_date( + currency.provider_reference, today.strftime("%m-%d-%Y") + ) + except Exception as e: + _logger.warning( + 'Currency Rate Provider "%(name)s" failed to obtain for %(currency)s currency' + % { + "name": self.name, + "currency": currency.currency_id.name, + }, + exc_info=True, + ) + self.message_post( + subject=_("Currency Rate Provider Failure"), + body=_( + 'Currency Rate Provider "%(name)s" failed to obtain data(check the rate provider mapping on the currency) :\n%(error)s' + ) + % { + "name": self.name, + "currency": currency.currency_id.name, + "error": str(e) if e else _("N/A"), + }, + ) + continue + rate = ( + coin_data.get("market_data") + .get("current_price") + .get(base_currency.lower(), 0) + ) + if rate: + data[today].update({currency.currency_id.name: 1 / rate}) + return data + + def _get_historical_rate_from_coingecko(self, date_from, date_to, base_currency): + """Get all the exchange rates from 'date_from' to 'date_to'""" + api = CoinGeckoAPI() + content = {} + current_date = date_from + while current_date <= date_to: + content[current_date] = {} + for ( + currency + ) in self.currency_ids.res_currency_rate_provider_mapping_ids.filtered( + lambda l: l.provider_id == self + ): + try: + coin_data = api.coin_historical_on_date( + currency.provider_reference, current_date.strftime("%m-%d-%Y") + ) + except Exception as e: + _logger.warning( + 'Currency Rate Provider "%(name)s" failed to obtain for %(currency)s currency' + % { + "name": self.name, + "currency": currency.currency_id.name, + }, + exc_info=True, + ) + self.message_post( + subject=_("Currency Rate Provider Failure"), + body=_( + 'Currency Rate Provider "%(name)s" failed to obtain data(check the rate provider mapping on the currency) :\n%(error)s' + ) + % { + "name": self.name, + "currency": currency.currency_id.name, + "error": str(e) if e else _("N/A"), + }, + ) + continue + rate = ( + coin_data.get("market_data") + .get("current_price") + .get(base_currency.lower(), 0) + ) + if rate: + content[current_date].update({currency.currency_id.name: 1 / rate}) + current_date += timedelta(days=1) + return content diff --git a/currency_rate_update_coingecko/tests/__init__.py b/currency_rate_update_coingecko/tests/__init__.py new file mode 100644 index 0000000..e9867ad --- /dev/null +++ b/currency_rate_update_coingecko/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_currency_rate_update_coingecko diff --git a/currency_rate_update_coingecko/tests/test_currency_rate_update_coingecko.py b/currency_rate_update_coingecko/tests/test_currency_rate_update_coingecko.py new file mode 100644 index 0000000..cb96420 --- /dev/null +++ b/currency_rate_update_coingecko/tests/test_currency_rate_update_coingecko.py @@ -0,0 +1,84 @@ +# Copyright 2024 Onestein +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.tests import common, tagged +from odoo.tools import mute_logger + + +@tagged("post_install", "-at_install") +class TestResCurrencyRateProviderCoinGecko(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.Company = cls.env["res.company"] + cls.CurrencyRate = cls.env["res.currency.rate"] + cls.Currency = cls.env["res.currency"] + cls.CurrencyRateProvider = cls.env["res.currency.rate.provider"] + cls.CurrencyRateProviderMapping = cls.env["res.currency.rate.provider.mapping"] + + cls.today = fields.Date.today() + cls.eur_currency = cls.env.ref("base.EUR") + cls.company = cls.Company.create( + {"name": "Test company", "currency_id": cls.eur_currency.id} + ) + cls.lnk_currency = cls.Currency.create({"name": "LINK", "symbol": "LNK"}) + cls.coingecko_provider = cls.CurrencyRateProvider.search( + [("service", "=", "CoinGecko")], limit=1 + ) + cls.coingecko_provider_mapping = cls.CurrencyRateProviderMapping.create( + { + "currency_id": cls.lnk_currency.id, + "provider_id": cls.coingecko_provider.id, + "provider_reference": "chainlink", + } + ) + cls.coingecko_provider.write( + { + "currency_ids": [ + (4, cls.lnk_currency.id), + ], + } + ) + cls.env.user.company_ids += cls.company + cls.env.company = cls.company + cls.CurrencyRate.search([]).unlink() + + def test_supported_currencies_CoinGecko(self): + self.coingecko_provider._get_supported_currencies() + + def test_cron(self): + self.coingecko_provider._scheduled_update() + rates = self.CurrencyRate.search([]) + self.assertTrue(rates) + self.assertEqual(rates.currency_id, self.lnk_currency) + self.CurrencyRate.search([("company_id", "=", self.company.id)]).unlink() + + def test_wizard(self): + wizard = ( + self.env["res.currency.rate.update.wizard"] + .with_context( + default_provider_ids=[(6, False, self.coingecko_provider.ids)] + ) + .create({}) + ) + wizard.action_update() + rates = self.CurrencyRate.search([]) + self.assertTrue(rates) + self.assertEqual(rates.currency_id, self.lnk_currency) + self.CurrencyRate.search([("company_id", "=", self.company.id)]).unlink() + + @mute_logger( + "odoo.addons.currency_rate_update_coingecko.models.res_currency_rate_provider_CoinGecko" + ) + def test_error_CoinGecko(self): + self.coingecko_provider_mapping.write({"provider_reference": "test"}) + self.coingecko_provider._update(self.today, self.today) + rates = self.CurrencyRate.search([]) + self.assertEqual(len(rates), 0) + self.coingecko_provider._update(self.today - relativedelta(days=2), self.today) + rates = self.CurrencyRate.search([]) + self.assertEqual(len(rates), 0) diff --git a/currency_rate_update_mapping/__init__.py b/currency_rate_update_mapping/__init__.py new file mode 100644 index 0000000..31660d6 --- /dev/null +++ b/currency_rate_update_mapping/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/currency_rate_update_mapping/__manifest__.py b/currency_rate_update_mapping/__manifest__.py new file mode 100644 index 0000000..8caf83a --- /dev/null +++ b/currency_rate_update_mapping/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2024 Onestein +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Currency Rate Update Mapping", + "version": "16.0.1.0.0", + "author": "Onestein BV", + "website": "https://www.onestein.nl", + "license": "AGPL-3", + "category": "Financial Management/Configuration", + "summary": "Allows to add mappings for currency rate providers", + "depends": ["currency_rate_update"], + "data": [ + "security/ir.model.access.csv", + "views/res_currency.xml", + ], + "installable": True, +} diff --git a/currency_rate_update_mapping/models/__init__.py b/currency_rate_update_mapping/models/__init__.py new file mode 100644 index 0000000..8c2085b --- /dev/null +++ b/currency_rate_update_mapping/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import res_currency +from . import res_currency_rate_provider_mapping diff --git a/currency_rate_update_mapping/models/res_currency.py b/currency_rate_update_mapping/models/res_currency.py new file mode 100644 index 0000000..c17f92c --- /dev/null +++ b/currency_rate_update_mapping/models/res_currency.py @@ -0,0 +1,16 @@ +# Copyright 2024 Onestein +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResCurrency(models.Model): + _inherit = "res.currency" + + res_currency_rate_provider_mapping_ids = fields.One2many( + comodel_name="res.currency.rate.provider.mapping", + inverse_name="currency_id", + string="Currency Rate Provider Mappings", + copy=False, + help="Currency mapping with the rate provider for updating rates", + ) diff --git a/currency_rate_update_mapping/models/res_currency_rate_provider_mapping.py b/currency_rate_update_mapping/models/res_currency_rate_provider_mapping.py new file mode 100644 index 0000000..e70ef96 --- /dev/null +++ b/currency_rate_update_mapping/models/res_currency_rate_provider_mapping.py @@ -0,0 +1,22 @@ +# Copyright 2024 Onestein +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ResCurrencyRateProviderMapping(models.Model): + _name = "res.currency.rate.provider.mapping" + _description = "Currency Rate Provider Mapping" + + currency_id = fields.Many2one( + string="Currency", + comodel_name="res.currency", + ) + provider_id = fields.Many2one( + string="Provider", + comodel_name="res.currency.rate.provider", + ondelete="restrict", + ) + provider_reference = fields.Char( + required=True, + help="Defines the reference to be used when fetching rates from the provider", + ) diff --git a/currency_rate_update_mapping/security/ir.model.access.csv b/currency_rate_update_mapping/security/ir.model.access.csv new file mode 100644 index 0000000..695f752 --- /dev/null +++ b/currency_rate_update_mapping/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_currency_rate_update_mapping_service_admin,res.currency.rate.provider.mapping,model_res_currency_rate_provider_mapping,base.group_system,1,1,1,1 +access_res_currency_rate_update_mapping_service_manager,res.currency.rate.provider.mapping,model_res_currency_rate_provider_mapping,account.group_account_manager,1,0,0,0 diff --git a/currency_rate_update_mapping/views/res_currency.xml b/currency_rate_update_mapping/views/res_currency.xml new file mode 100644 index 0000000..65c2003 --- /dev/null +++ b/currency_rate_update_mapping/views/res_currency.xml @@ -0,0 +1,25 @@ + + + + + res.currency.form + res.currency + + + + + + + + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 5471bf1..66c3eb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,3 +29,4 @@ xlrd python-slugify vcrpy dnspython==2.6.1 +pycgapi