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