feat(currencies): add automatic exchange rate fetching

Closes #123
This commit is contained in:
Herculino Trotta
2025-02-05 10:16:04 -03:00
parent 80edf557cb
commit d207760ae9
18 changed files with 1244 additions and 343 deletions
@@ -6,7 +6,8 @@ from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
CoinGeckoProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
)
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
@@ -16,7 +17,8 @@ logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"coingecko": CoinGeckoProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
}
@@ -31,7 +33,7 @@ class ExchangeRateFetcher:
If False, only fetches services that are due according to their interval.
"""
services = ExchangeRateService.objects.filter(is_active=True)
current_time = timezone.now()
current_time = timezone.now().replace(minute=0, second=0, microsecond=0)
for service in services:
try:
@@ -47,9 +49,9 @@ class ExchangeRateFetcher:
continue
# Calculate when the next fetch should occur
next_fetch_due = service.last_fetch + timedelta(
hours=service.fetch_interval_hours
)
next_fetch_due = (
service.last_fetch + timedelta(hours=service.fetch_interval_hours)
).replace(minute=0, second=0, microsecond=0)
# Check if it's time for the next fetch
if current_time >= next_fetch_due:
@@ -77,8 +77,8 @@ class SynthFinanceProvider(ExchangeRateProvider):
return results
class CoinGeckoProvider(ExchangeRateProvider):
"""Implementation for CoinGecko API"""
class CoinGeckoFreeProvider(ExchangeRateProvider):
"""Implementation for CoinGecko Free API"""
BASE_URL = "https://api.coingecko.com/api/v3"
rates_inverted = True
@@ -138,3 +138,15 @@ class CoinGeckoProvider(ExchangeRateProvider):
logger.error(f"Error fetching rates from CoinGecko API: {e}")
return results
class CoinGeckoProProvider(CoinGeckoFreeProvider):
"""Implementation for CoinGecko Pro API"""
BASE_URL = "https://pro-api.coingecko.com/api/v3/simple/price"
rates_inverted = True
def __init__(self, api_key: str):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-pro-api-key": api_key})
+49 -1
View File
@@ -1,3 +1,4 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout
@@ -9,7 +10,7 @@ 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.models import Currency, ExchangeRate
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
class CurrencyForm(forms.ModelForm):
@@ -99,3 +100,50 @@ class ExchangeRateForm(forms.ModelForm):
),
),
)
class ExchangeRateServiceForm(forms.ModelForm):
class Meta:
model = ExchangeRateService
fields = [
"name",
"service_type",
"is_active",
"api_key",
"fetch_interval_hours",
"target_currencies",
"target_accounts",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
"service_type",
Switch("is_active"),
"api_key",
"fetch_interval_hours",
"target_currencies",
"target_accounts",
)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
+2 -1
View File
@@ -91,7 +91,8 @@ class ExchangeRateService(models.Model):
class ServiceType(models.TextChoices):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
COINGECKO = "coingecko", "CoinGecko"
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
name = models.CharField(max_length=255, unique=True, verbose_name=_("Service Name"))
service_type = models.CharField(
+1 -2
View File
@@ -1,9 +1,8 @@
import logging
from procrastinate.contrib.django import app
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
from apps.currencies.models import ExchangeRateService
from django.utils import timezone
logger = logging.getLogger(__name__)
+30
View File
@@ -34,4 +34,34 @@ urlpatterns = [
views.exchange_rate_delete,
name="exchange_rate_delete",
),
path(
"automatic-exchange-rates/",
views.exchange_rates_services_index,
name="automatic_exchange_rates_index",
),
path(
"automatic-exchange-rates/list/",
views.exchange_rates_services_list,
name="automatic_exchange_rates_list",
),
path(
"automatic-exchange-rates/add/",
views.exchange_rate_service_add,
name="automatic_exchange_rate_add",
),
path(
"automatic-exchange-rates/force-fetch/",
views.exchange_rate_service_force_fetch,
name="automatic_exchange_rate_force_fetch",
),
path(
"automatic-exchange-rates/<int:pk>/edit/",
views.exchange_rate_service_edit,
name="automatic_exchange_rate_edit",
),
path(
"automatic-exchange-rates/<int:pk>/delete/",
views.exchange_rate_service_delete,
name="automatic_exchange_rate_delete",
),
]
+1
View File
@@ -1,2 +1,3 @@
from .currencies import *
from .exchange_rates import *
from .exchange_rates_services import *
@@ -0,0 +1,122 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
from apps.currencies.models import ExchangeRate, ExchangeRateService
from apps.currencies.tasks import manual_fetch_exchange_rates
@login_required
@require_http_methods(["GET"])
def exchange_rates_services_index(request):
return render(
request,
"exchange_rates_services/pages/index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def exchange_rates_services_list(request):
services = ExchangeRateService.objects.all()
return render(
request,
"exchange_rates_services/fragments/list.html",
{"services": services},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def exchange_rate_service_add(request):
if request.method == "POST":
form = ExchangeRateServiceForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Service added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ExchangeRateServiceForm()
return render(
request,
"exchange_rates_services/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def exchange_rate_service_edit(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
if request.method == "POST":
form = ExchangeRateServiceForm(request.POST, instance=service)
if form.is_valid():
form.save()
messages.success(request, _("Service updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ExchangeRateServiceForm(instance=service)
return render(
request,
"exchange_rates_services/fragments/edit.html",
{"form": form, "service": service},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def exchange_rate_service_delete(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
service.delete()
messages.success(request, _("Service deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def exchange_rate_service_force_fetch(request):
manual_fetch_exchange_rates.defer()
messages.success(request, _("Services queued successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "toasts",
},
)