mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-24 01:28:42 +02:00
refactor(automatic-exchange-rates): replace fetch_interval with fetch interval type and fetch interval
This commit is contained in:
@@ -17,7 +17,8 @@ class ExchangeRateServiceAdmin(admin.ModelAdmin):
|
|||||||
"name",
|
"name",
|
||||||
"service_type",
|
"service_type",
|
||||||
"is_active",
|
"is_active",
|
||||||
"fetch_interval_hours",
|
"interval_type",
|
||||||
|
"fetch_interval",
|
||||||
"last_fetch",
|
"last_fetch",
|
||||||
]
|
]
|
||||||
list_filter = ["is_active", "service_type"]
|
list_filter = ["is_active", "service_type"]
|
||||||
|
|||||||
@@ -23,17 +23,67 @@ PROVIDER_MAPPING = {
|
|||||||
|
|
||||||
|
|
||||||
class ExchangeRateFetcher:
|
class ExchangeRateFetcher:
|
||||||
|
def _should_fetch_at_hour(service: ExchangeRateService, current_hour: int) -> bool:
|
||||||
|
"""Check if service should fetch rates at given hour based on interval type."""
|
||||||
|
try:
|
||||||
|
if service.interval_type == ExchangeRateService.IntervalType.NOT_ON:
|
||||||
|
blocked_hours = ExchangeRateService._parse_hour_ranges(
|
||||||
|
service.fetch_interval
|
||||||
|
)
|
||||||
|
should_fetch = current_hour not in blocked_hours
|
||||||
|
logger.debug(
|
||||||
|
f"NOT_ON check for {service.name}: "
|
||||||
|
f"current_hour={current_hour}, "
|
||||||
|
f"blocked_hours={blocked_hours}, "
|
||||||
|
f"should_fetch={should_fetch}"
|
||||||
|
)
|
||||||
|
return should_fetch
|
||||||
|
|
||||||
|
if service.interval_type == ExchangeRateService.IntervalType.ON:
|
||||||
|
allowed_hours = ExchangeRateService._parse_hour_ranges(
|
||||||
|
service.fetch_interval
|
||||||
|
)
|
||||||
|
return current_hour in allowed_hours
|
||||||
|
|
||||||
|
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
|
||||||
|
try:
|
||||||
|
interval_hours = int(service.fetch_interval)
|
||||||
|
if service.last_fetch is None:
|
||||||
|
return True
|
||||||
|
hours_since_last = (
|
||||||
|
timezone.now() - service.last_fetch
|
||||||
|
).total_seconds() / 3600
|
||||||
|
should_fetch = hours_since_last >= interval_hours
|
||||||
|
logger.debug(
|
||||||
|
f"EVERY check for {service.name}: "
|
||||||
|
f"hours_since_last={hours_since_last:.1f}, "
|
||||||
|
f"interval={interval_hours}, "
|
||||||
|
f"should_fetch={should_fetch}"
|
||||||
|
)
|
||||||
|
return should_fetch
|
||||||
|
except ValueError:
|
||||||
|
logger.error(
|
||||||
|
f"Invalid EVERY interval format for {service.name}: "
|
||||||
|
f"expected single number, got '{service.fetch_interval}'"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Error parsing fetch_interval for {service.name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fetch_due_rates(force: bool = False) -> None:
|
def fetch_due_rates(force: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Fetch rates for all services that are due for update.
|
Fetch rates for all services that are due for update.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force (bool): If True, fetches all active services regardless of their last fetch time.
|
force (bool): If True, fetches all active services regardless of their schedule.
|
||||||
If False, only fetches services that are due according to their interval.
|
|
||||||
"""
|
"""
|
||||||
services = ExchangeRateService.objects.filter(is_active=True)
|
services = ExchangeRateService.objects.filter(is_active=True)
|
||||||
current_time = timezone.now().replace(minute=0, second=0, microsecond=0)
|
current_time = timezone.now().astimezone()
|
||||||
|
current_hour = current_time.hour
|
||||||
|
|
||||||
for service in services:
|
for service in services:
|
||||||
try:
|
try:
|
||||||
@@ -42,29 +92,21 @@ class ExchangeRateFetcher:
|
|||||||
ExchangeRateFetcher._fetch_service_rates(service)
|
ExchangeRateFetcher._fetch_service_rates(service)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Regular schedule-based fetching
|
# Check if service should fetch based on interval type
|
||||||
if service.last_fetch is None:
|
if ExchangeRateFetcher._should_fetch_at_hour(service, current_hour):
|
||||||
logger.info(f"First fetch for service: {service.name}")
|
|
||||||
ExchangeRateFetcher._fetch_service_rates(service)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Calculate when the next fetch should occur
|
|
||||||
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:
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Fetching rates for {service.name}. "
|
f"Fetching rates for {service.name}. "
|
||||||
f"Last fetch: {service.last_fetch}, "
|
f"Last fetch: {service.last_fetch}, "
|
||||||
f"Interval: {service.fetch_interval_hours}h"
|
f"Interval type: {service.interval_type}, "
|
||||||
|
f"Current hour: {current_hour}"
|
||||||
)
|
)
|
||||||
ExchangeRateFetcher._fetch_service_rates(service)
|
ExchangeRateFetcher._fetch_service_rates(service)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Skipping {service.name}. "
|
f"Skipping {service.name}. "
|
||||||
f"Next fetch due at: {next_fetch_due}"
|
f"Current hour: {current_hour}, "
|
||||||
|
f"Interval type: {service.interval_type}, "
|
||||||
|
f"Fetch interval: {service.fetch_interval}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from crispy_bootstrap5.bootstrap5 import Switch
|
from crispy_bootstrap5.bootstrap5 import Switch
|
||||||
from crispy_forms.bootstrap import FormActions
|
from crispy_forms.bootstrap import FormActions
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout
|
from crispy_forms.layout import Layout, Row, Column
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import CharField
|
from django.forms import CharField
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -110,7 +110,8 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
|||||||
"service_type",
|
"service_type",
|
||||||
"is_active",
|
"is_active",
|
||||||
"api_key",
|
"api_key",
|
||||||
"fetch_interval_hours",
|
"interval_type",
|
||||||
|
"fetch_interval",
|
||||||
"target_currencies",
|
"target_currencies",
|
||||||
"target_accounts",
|
"target_accounts",
|
||||||
]
|
]
|
||||||
@@ -126,7 +127,10 @@ class ExchangeRateServiceForm(forms.ModelForm):
|
|||||||
"service_type",
|
"service_type",
|
||||||
Switch("is_active"),
|
Switch("is_active"),
|
||||||
"api_key",
|
"api_key",
|
||||||
"fetch_interval_hours",
|
Row(
|
||||||
|
Column("interval_type", css_class="form-group col-md-6"),
|
||||||
|
Column("fetch_interval", css_class="form-group col-md-6"),
|
||||||
|
),
|
||||||
"target_currencies",
|
"target_currencies",
|
||||||
"target_accounts",
|
"target_accounts",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-07 02:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0010_alter_currency_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='fetch_interval_hours',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='fetch_interval',
|
||||||
|
field=models.CharField(default='24', max_length=1000, verbose_name='Interval'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='interval_type',
|
||||||
|
field=models.CharField(choices=[('on', 'On'), ('every', 'Every X hours'), ('not_on', 'Not on')], default='every', max_length=255, verbose_name='Interval Type'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
@@ -94,6 +95,11 @@ class ExchangeRateService(models.Model):
|
|||||||
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||||
|
|
||||||
|
class IntervalType(models.TextChoices):
|
||||||
|
ON = "on", _("On")
|
||||||
|
EVERY = "every", _("Every X hours")
|
||||||
|
NOT_ON = "not_on", _("Not on")
|
||||||
|
|
||||||
name = models.CharField(max_length=255, unique=True, verbose_name=_("Service Name"))
|
name = models.CharField(max_length=255, unique=True, verbose_name=_("Service Name"))
|
||||||
service_type = models.CharField(
|
service_type = models.CharField(
|
||||||
max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type")
|
max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type")
|
||||||
@@ -106,10 +112,14 @@ class ExchangeRateService(models.Model):
|
|||||||
verbose_name=_("API Key"),
|
verbose_name=_("API Key"),
|
||||||
help_text=_("API key for the service (if required)"),
|
help_text=_("API key for the service (if required)"),
|
||||||
)
|
)
|
||||||
fetch_interval_hours = models.PositiveIntegerField(
|
interval_type = models.CharField(
|
||||||
default=24,
|
max_length=255,
|
||||||
validators=[MinValueValidator(1)],
|
choices=IntervalType.choices,
|
||||||
verbose_name=_("Fetch Interval (hours)"),
|
verbose_name=_("Interval Type"),
|
||||||
|
default=IntervalType.EVERY,
|
||||||
|
)
|
||||||
|
fetch_interval = models.CharField(
|
||||||
|
max_length=1000, verbose_name=_("Interval"), default="24"
|
||||||
)
|
)
|
||||||
last_fetch = models.DateTimeField(
|
last_fetch = models.DateTimeField(
|
||||||
null=True, blank=True, verbose_name=_("Last Successful Fetch")
|
null=True, blank=True, verbose_name=_("Last Successful Fetch")
|
||||||
@@ -148,3 +158,82 @@ class ExchangeRateService(models.Model):
|
|||||||
|
|
||||||
provider_class = PROVIDER_MAPPING[self.service_type]
|
provider_class = PROVIDER_MAPPING[self.service_type]
|
||||||
return provider_class(self.api_key)
|
return provider_class(self.api_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_hour_ranges(interval_str: str) -> Set[int]:
|
||||||
|
"""
|
||||||
|
Parse hour ranges and individual hours from string.
|
||||||
|
|
||||||
|
Valid formats:
|
||||||
|
- Single hours: "1,5,9"
|
||||||
|
- Ranges: "1-5"
|
||||||
|
- Mixed: "1-5,8,10-12"
|
||||||
|
|
||||||
|
Returns set of hours.
|
||||||
|
"""
|
||||||
|
hours = set()
|
||||||
|
|
||||||
|
for part in interval_str.strip().split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if "-" in part:
|
||||||
|
start, end = part.split("-")
|
||||||
|
start, end = int(start), int(end)
|
||||||
|
if not (0 <= start <= 23 and 0 <= end <= 23):
|
||||||
|
raise ValueError("Hours must be between 0 and 23")
|
||||||
|
if start > end:
|
||||||
|
raise ValueError(f"Invalid range: {start}-{end}")
|
||||||
|
hours.update(range(start, end + 1))
|
||||||
|
else:
|
||||||
|
hour = int(part)
|
||||||
|
if not 0 <= hour <= 23:
|
||||||
|
raise ValueError("Hours must be between 0 and 23")
|
||||||
|
hours.add(hour)
|
||||||
|
|
||||||
|
return hours
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
try:
|
||||||
|
if self.interval_type == self.IntervalType.EVERY:
|
||||||
|
if not self.fetch_interval.isdigit():
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"'Every X hours' interval type requires a positive integer."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hours = int(self.fetch_interval)
|
||||||
|
if hours < 1 or hours > 24:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"'Every X hours' interval must be between 1 and 24."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Parse and validate hour ranges
|
||||||
|
hours = self._parse_hour_ranges(self.fetch_interval)
|
||||||
|
# Store in normalized format (optional)
|
||||||
|
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"Invalid hour format. Use comma-separated hours (0-23) "
|
||||||
|
"and/or ranges (e.g., '1-5,8,10-12')."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"fetch_interval": _(
|
||||||
|
"Invalid format. Please check the requirements for your selected interval type."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
<th scope="col" class="col-auto">{% translate 'Name' %}</th>
|
<th scope="col" class="col-auto">{% translate 'Name' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Service' %}</th>
|
<th scope="col" class="col">{% translate 'Service' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Targeting' %}</th>
|
<th scope="col" class="col">{% translate 'Targeting' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Fetch every' %}</th>
|
|
||||||
<th scope="col" class="col">{% translate 'Last fetch' %}</th>
|
<th scope="col" class="col">{% translate 'Last fetch' %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -66,7 +65,6 @@
|
|||||||
<td class="col-auto">{{ service.name }}</td>
|
<td class="col-auto">{{ service.name }}</td>
|
||||||
<td class="col">{{ service.get_service_type_display }}</td>
|
<td class="col">{{ service.get_service_type_display }}</td>
|
||||||
<td class="col">{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td>
|
<td class="col">{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td>
|
||||||
<td class="col">{{ service.fetch_interval_hours }} {% trans 'hours' %}</td>
|
|
||||||
<td class="col">{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>
|
<td class="col">{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user