mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
1 parent
a50bdb10ff
commit
43145be24d
6 files changed
+151
-24
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
+12
-6
@@ -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",
|
||||
@@ -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": ""})
|
||||
Reference in new issue
Block a user