From 43145be24daaaa620bcdde3bd06e13b6c3814a3b Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 8 Nov 2026 02:57:07 -0300 Subject: [PATCH] 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. --- app/apps/currencies/exchange_rates/base.py | 10 +++- .../currencies/exchange_rates/providers.py | 30 +++++++--- app/apps/currencies/forms.py | 42 +++++++++++++ ...py => 0026_exchangerateservice_options.py} | 18 ++++-- app/apps/currencies/models.py | 15 +++-- .../tests/test_allratestoday_provider.py | 60 +++++++++++++++++-- 6 files changed, 151 insertions(+), 24 deletions(-) rename app/apps/currencies/migrations/{0026_alter_exchangerateservice_service_type.py => 0026_exchangerateservice_options.py} (69%) diff --git a/app/apps/currencies/exchange_rates/base.py b/app/apps/currencies/exchange_rates/base.py index 636d49fd..098ce5bd 100644 --- a/app/apps/currencies/exchange_rates/base.py +++ b/app/apps/currencies/exchange_rates/base.py @@ -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.""" diff --git a/app/apps/currencies/exchange_rates/providers.py b/app/apps/currencies/exchange_rates/providers.py index eacb580b..a1ef9211 100644 --- a/app/apps/currencies/exchange_rates/providers.py +++ b/app/apps/currencies/exchange_rates/providers.py @@ -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 diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index 3c9ff64f..3090e8d5 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -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 diff --git a/app/apps/currencies/migrations/0026_alter_exchangerateservice_service_type.py b/app/apps/currencies/migrations/0026_exchangerateservice_options.py similarity index 69% rename from app/apps/currencies/migrations/0026_alter_exchangerateservice_service_type.py rename to app/apps/currencies/migrations/0026_exchangerateservice_options.py index ffe19948..57681750 100644 --- a/app/apps/currencies/migrations/0026_alter_exchangerateservice_service_type.py +++ b/app/apps/currencies/migrations/0026_exchangerateservice_options.py @@ -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", diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index 36287000..cd01d36b 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -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]: diff --git a/app/apps/currencies/tests/test_allratestoday_provider.py b/app/apps/currencies/tests/test_allratestoday_provider.py index b73d05d7..424dd9bb 100644 --- a/app/apps/currencies/tests/test_allratestoday_provider.py +++ b/app/apps/currencies/tests/test_allratestoday_provider.py @@ -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": ""})