feat(automatic-exchange-rates): add provider options field

Add an Options field to exchange rate services. Providers get the raw
text, can validate it on save through clean_options, and describe it
with options_help.

The service form now shows API Key only for providers that require one,
and Options only for providers that read it, with that provider's help.

AllRatesToday reads its bank code from Options instead of API Key.
This commit is contained in:
Herculino Trotta committed 2026-11-08 02:57:07 -03:00
1 parent a50bdb10ff
commit 43145be24d
6 files changed
+151 -24

No files matched your search

+9 -1
View File
@@ -8,9 +8,12 @@ from apps.currencies.models import Currency
class ExchangeRateProvider(ABC):
rates_inverted = False
# Help shown under the service's Options field. None hides the field
options_help = None
def __init__(self, api_key: Optional[str] = None):
def __init__(self, api_key: Optional[str] = None, options: str = ""):
self.api_key = api_key
self.options = options or ""
@abstractmethod
def get_rates(
@@ -24,6 +27,11 @@ class ExchangeRateProvider(ABC):
"""Return True if the service requires an API key"""
return True
@classmethod
def clean_options(cls, options: str) -> str:
"""Validate the raw Options text on save. Raise ValidationError if invalid"""
return options
@staticmethod
def invert_rate(rate: Decimal) -> Decimal:
"""Invert the given rate."""
@@ -6,7 +6,9 @@ import requests
from decimal import Decimal
from typing import Tuple, List, Optional, Dict
from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.exchange_rates.base import ExchangeRateProvider
@@ -688,14 +690,18 @@ class AllRatesTodayProvider(ExchangeRateProvider):
"""
Implementation for the central bank tables served by allratestoday.com.
Each service reads the latest published table of one central bank. The API
Key field holds the bank code (e.g. "bcb" or "rbi"), as this endpoint needs
no key. An empty field reads the ECB table.
Each service reads the latest published table of one central bank, picked by
the bank code in the Options field (e.g. "bcb" or "rbi"). An empty field
reads the ECB table. The endpoint needs no API key.
"""
BASE_URL = "https://allratestoday.com/api/open/central-bank"
DEFAULT_BANK = "ecb"
BANK_CODE_RE = re.compile(r"[a-z0-9_-]{2,20}")
rates_inverted = False # A base/quote row says how many quote one base buys
options_help = _(
"Central bank code, e.g. 'bcb' or 'rbi'. Leave empty to use the ECB table."
)
# A pair can have several rows. Prefer a published mid, else the mean of a buy
# and sell quote. Period averages are left out, they are not a current rate
@@ -710,15 +716,25 @@ class AllRatesTodayProvider(ExchangeRateProvider):
)
BUY_SELL_TYPES = (("buy", "sell"), ("tt_buy", "tt_sell"))
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.bank = (api_key or self.DEFAULT_BANK).strip().lower()
def __init__(self, api_key: str = None, options: str = ""):
super().__init__(api_key, options)
self.session = requests.Session()
@property
def bank(self) -> str:
return self.options.strip().lower() or self.DEFAULT_BANK
@classmethod
def requires_api_key(cls) -> bool:
return False
@classmethod
def clean_options(cls, options: str) -> str:
bank = options.strip().lower()
if bank and not cls.BANK_CODE_RE.fullmatch(bank):
raise ValidationError(_("Invalid bank code, it should look like 'bcb'."))
return bank
def _parse_table(self, rows: list) -> Dict[Tuple[str, str], Decimal]:
"""Reduce the table to one rate per pair, in both directions"""
grouped = {}
@@ -766,7 +782,7 @@ class AllRatesTodayProvider(ExchangeRateProvider):
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
if not re.fullmatch(r"[a-z0-9_-]{2,20}", self.bank):
if not self.BANK_CODE_RE.fullmatch(self.bank):
logger.error("Invalid AllRatesToday bank code, it should look like 'bcb'")
return results
+42
View File
@@ -1,13 +1,17 @@
import json
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDateTimePickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Layout, Row
from django import forms
from django.core.exceptions import ValidationError
from django.forms import CharField
from django.utils.translation import gettext_lazy as _
@@ -103,16 +107,41 @@ class ExchangeRateServiceForm(forms.ModelForm):
"service_type",
"is_active",
"api_key",
"options",
"interval_type",
"fetch_interval",
"target_currencies",
"target_accounts",
"singleton",
]
widgets = {"options": forms.Textarea(attrs={"rows": 2})}
# Shows API Key and Options only for providers that use them
SERVICE_TYPE_SCRIPT = """
init send change to me end
on change js(me)
const p = JSON.parse(me.dataset.providers)[me.value] || {};
document.getElementById('div_id_api_key').classList.toggle('hidden', !p.api_key);
document.getElementById('div_id_options').classList.toggle('hidden', !p.options);
const help = document.getElementById('id_options_helptext');
if (help) help.textContent = p.options || '';
end
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
providers = {
key: {
"api_key": provider.requires_api_key(),
"options": str(provider.options_help or ""),
}
for key, provider in PROVIDER_MAPPING.items()
}
self.fields["service_type"].widget.attrs.update(
{"data-providers": json.dumps(providers), "_": self.SERVICE_TYPE_SCRIPT}
)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
@@ -122,6 +151,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
Switch("is_active"),
Switch("singleton"),
"api_key",
"options",
Row(
Column("interval_type"),
Column("fetch_interval"),
@@ -142,3 +172,15 @@ class ExchangeRateServiceForm(forms.ModelForm):
NoClassSubmit("submit", _("Add"), css_class="btn btn-primary"),
),
)
def clean(self):
cleaned_data = super().clean()
provider = PROVIDER_MAPPING.get(cleaned_data.get("service_type"))
if provider:
try:
cleaned_data["options"] = provider.clean_options(
cleaned_data.get("options") or ""
)
except ValidationError as e:
self.add_error("options", e)
return cleaned_data
@@ -1,15 +1,24 @@
# Generated by Django 5.2.17 on 2026-11-06 22:51
# Generated by Django 5.2.17 on 2026-11-08 05:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("currencies", "0025_alter_exchangerateservice_service_type"),
]
operations = [
migrations.AddField(
model_name="exchangerateservice",
name="options",
field=models.TextField(
blank=True,
default="",
help_text="Provider specific settings",
verbose_name="Options",
),
),
migrations.AlterField(
model_name="exchangerateservice",
name="service_type",
@@ -23,10 +32,7 @@ class Migration(migrations.Migration):
("twelvedatamarkets", "TwelveData Markets"),
("yfinance", "Yahoo Finance"),
("fxmacrodata", "FXMacroData"),
(
"allratestoday",
"AllRatesToday Central Banks (bank code as API key)",
),
("allratestoday", "AllRatesToday Central Banks"),
],
max_length=255,
verbose_name="Service Type",
+10 -5
View File
@@ -107,10 +107,7 @@ class ExchangeRateService(models.Model):
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
YFINANCE = "yfinance", "Yahoo Finance"
FXMACRODATA = "fxmacrodata", "FXMacroData"
ALLRATESTODAY = (
"allratestoday",
"AllRatesToday Central Banks (bank code as API key)",
)
ALLRATESTODAY = "allratestoday", "AllRatesToday Central Banks"
class IntervalType(models.TextChoices):
ON = "on", _("On")
@@ -129,6 +126,12 @@ class ExchangeRateService(models.Model):
verbose_name=_("API Key"),
help_text=_("API key for the service (if required)"),
)
options = models.TextField(
blank=True,
default="",
verbose_name=_("Options"),
help_text=_("Provider specific settings"),
)
interval_type = models.CharField(
max_length=255,
choices=IntervalType.choices,
@@ -184,7 +187,9 @@ class ExchangeRateService(models.Model):
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
provider_class = PROVIDER_MAPPING[self.service_type]
return provider_class(self.api_key)
provider = provider_class(self.api_key)
provider.options = self.options or ""
return provider
@staticmethod
def _parse_hour_ranges(interval_str: str) -> Set[int]:
@@ -1,11 +1,15 @@
import json
from decimal import Decimal
from unittest import TestCase
from unittest.mock import MagicMock, patch
import requests
from django.core.exceptions import ValidationError
from django.test import TestCase as DjangoTestCase
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
from apps.currencies.exchange_rates.providers import AllRatesTodayProvider
from apps.currencies.forms import ExchangeRateServiceForm
from apps.currencies.models import ExchangeRateService
@@ -31,7 +35,7 @@ class AllRatesTodayProviderTests(TestCase):
self.brl = _FakeCurrency("BRL")
self.usd = _FakeCurrency("USD")
self.eur = _FakeCurrency("EUR")
self.provider = AllRatesTodayProvider("bcb")
self.provider = AllRatesTodayProvider(options="bcb")
def _get_rates(self, rows, target, base):
target.exchange_currency = base
@@ -67,14 +71,23 @@ class AllRatesTodayProviderTests(TestCase):
self.assertEqual(len(rates), 2)
def test_empty_field_reads_the_ecb_table(self):
self.assertEqual(AllRatesTodayProvider(None).bank, "ecb")
self.assertEqual(AllRatesTodayProvider("").bank, "ecb")
for options in (None, "", " "):
self.assertEqual(AllRatesTodayProvider(options=options).bank, "ecb")
def test_normalizes_the_bank_code(self):
self.assertEqual(AllRatesTodayProvider(" BCB ").bank, "bcb")
self.assertEqual(AllRatesTodayProvider(options=" BCB ").bank, "bcb")
def test_ignores_the_api_key(self):
self.assertEqual(AllRatesTodayProvider("bcb").bank, "ecb")
def test_clean_options_validates_the_bank_code(self):
self.assertEqual(AllRatesTodayProvider.clean_options(" BCB "), "bcb")
self.assertEqual(AllRatesTodayProvider.clean_options(""), "")
with self.assertRaises(ValidationError):
AllRatesTodayProvider.clean_options("../latest/USD")
def test_rejects_malformed_bank_code_without_a_request(self):
provider = AllRatesTodayProvider("../latest/USD")
provider = AllRatesTodayProvider(options="../latest/USD")
self.usd.exchange_currency = self.brl
with patch.object(provider.session, "get") as mock_get:
@@ -206,3 +219,40 @@ class AllRatesTodayProviderTests(TestCase):
self.assertFalse(AllRatesTodayProvider.requires_api_key())
self.assertIs(PROVIDER_MAPPING["allratestoday"], AllRatesTodayProvider)
self.assertEqual(ExchangeRateService.ServiceType.ALLRATESTODAY, "allratestoday")
def test_service_passes_its_options_to_the_provider(self):
service = ExchangeRateService(service_type="allratestoday", options="rbi")
self.assertEqual(service.get_provider().bank, "rbi")
class ExchangeRateServiceFormOptionsTests(DjangoTestCase):
def _form(self, **data):
return ExchangeRateServiceForm(
data={
"name": "Rates",
"service_type": "allratestoday",
"interval_type": "every",
"fetch_interval": "24",
**data,
}
)
def test_saves_cleaned_options(self):
form = self._form(options=" BCB ")
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.save().options, "bcb")
def test_rejects_invalid_options_on_save(self):
form = self._form(options="../latest/USD")
self.assertFalse(form.is_valid())
self.assertIn("options", form.errors)
def test_tells_the_page_which_fields_each_provider_uses(self):
attrs = self._form().fields["service_type"].widget.attrs
providers = json.loads(attrs["data-providers"])
self.assertEqual(providers["allratestoday"]["api_key"], False)
self.assertIn("bank code", providers["allratestoday"]["options"])
self.assertEqual(providers["twelvedata"], {"api_key": True, "options": ""})