diff --git a/app/apps/currencies/admin.py b/app/apps/currencies/admin.py index ead88ad..17d3d2e 100644 --- a/app/apps/currencies/admin.py +++ b/app/apps/currencies/admin.py @@ -17,7 +17,8 @@ class ExchangeRateServiceAdmin(admin.ModelAdmin): "name", "service_type", "is_active", - "fetch_interval_hours", + "interval_type", + "fetch_interval", "last_fetch", ] list_filter = ["is_active", "service_type"] diff --git a/app/apps/currencies/exchange_rates/fetcher.py b/app/apps/currencies/exchange_rates/fetcher.py index 3e763c3..fffc935 100644 --- a/app/apps/currencies/exchange_rates/fetcher.py +++ b/app/apps/currencies/exchange_rates/fetcher.py @@ -23,17 +23,67 @@ PROVIDER_MAPPING = { 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 def fetch_due_rates(force: bool = False) -> None: """ Fetch rates for all services that are due for update. - Args: - force (bool): If True, fetches all active services regardless of their last fetch time. - If False, only fetches services that are due according to their interval. + force (bool): If True, fetches all active services regardless of their schedule. """ 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: try: @@ -42,29 +92,21 @@ class ExchangeRateFetcher: ExchangeRateFetcher._fetch_service_rates(service) continue - # Regular schedule-based fetching - if service.last_fetch is None: - 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: + # Check if service should fetch based on interval type + if ExchangeRateFetcher._should_fetch_at_hour(service, current_hour): logger.info( f"Fetching rates for {service.name}. " 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) else: logger.debug( 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: diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index f34e605..317bd0d 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -1,7 +1,7 @@ from crispy_bootstrap5.bootstrap5 import Switch from crispy_forms.bootstrap import FormActions 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.forms import CharField from django.utils.translation import gettext_lazy as _ @@ -110,7 +110,8 @@ class ExchangeRateServiceForm(forms.ModelForm): "service_type", "is_active", "api_key", - "fetch_interval_hours", + "interval_type", + "fetch_interval", "target_currencies", "target_accounts", ] @@ -126,7 +127,10 @@ class ExchangeRateServiceForm(forms.ModelForm): "service_type", Switch("is_active"), "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_accounts", ) diff --git a/app/apps/currencies/migrations/0011_remove_exchangerateservice_fetch_interval_hours_and_more.py b/app/apps/currencies/migrations/0011_remove_exchangerateservice_fetch_interval_hours_and_more.py new file mode 100644 index 0000000..81e7a1d --- /dev/null +++ b/app/apps/currencies/migrations/0011_remove_exchangerateservice_fetch_interval_hours_and_more.py @@ -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'), + ), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index 698745a..5123142 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -1,4 +1,5 @@ import logging +from typing import Set from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -94,6 +95,11 @@ class ExchangeRateService(models.Model): COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)" 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")) service_type = models.CharField( max_length=255, choices=ServiceType.choices, verbose_name=_("Service Type") @@ -106,10 +112,14 @@ class ExchangeRateService(models.Model): verbose_name=_("API Key"), help_text=_("API key for the service (if required)"), ) - fetch_interval_hours = models.PositiveIntegerField( - default=24, - validators=[MinValueValidator(1)], - verbose_name=_("Fetch Interval (hours)"), + interval_type = models.CharField( + max_length=255, + choices=IntervalType.choices, + verbose_name=_("Interval Type"), + default=IntervalType.EVERY, + ) + fetch_interval = models.CharField( + max_length=1000, verbose_name=_("Interval"), default="24" ) last_fetch = models.DateTimeField( null=True, blank=True, verbose_name=_("Last Successful Fetch") @@ -148,3 +158,82 @@ class ExchangeRateService(models.Model): provider_class = PROVIDER_MAPPING[self.service_type] 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." + ) + } + ) diff --git a/app/templates/exchange_rates_services/fragments/list.html b/app/templates/exchange_rates_services/fragments/list.html index fca5aa8..1c42393 100644 --- a/app/templates/exchange_rates_services/fragments/list.html +++ b/app/templates/exchange_rates_services/fragments/list.html @@ -32,7 +32,6 @@