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": ""})