mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
Currencies and accounts get a "Show exchanged amounts" switch, on by default. Turning it off hides the amounts converted to the exchange currency without removing the exchange currency, so consolidated totals on the Net Worth page keep working. The switch belongs to whichever one provides the exchange currency: - Transactions (including both legs of combined transfers) use the account's switch when the account has its own exchange currency, and the currency's switch otherwise, matching how the exchanged amount is picked. - Account cards and Net Worth account rows follow the account. - Currency cards, the monthly summary and daily allowance, Net Worth currency rows and the month-by-month and year-by-year insights follow the currency. Currency totals still convert hidden currencies, because the consolidated totals are built from those values; the setting only leaves them out of the displayed data. The API keeps returning exchanged_amount for transactions, and the account serializer exposes the new field (currencies already serialize all fields). Closes #486
290 lines
9.9 KiB
Python
290 lines
9.9 KiB
Python
import logging
|
|
from typing import Set
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Currency(models.Model):
|
|
code = models.CharField(
|
|
max_length=255, unique=False, verbose_name=_("Currency Code")
|
|
)
|
|
name = models.CharField(max_length=50, verbose_name=_("Currency Name"), unique=True)
|
|
decimal_places = models.PositiveIntegerField(
|
|
default=2,
|
|
validators=[MaxValueValidator(30), MinValueValidator(0)],
|
|
verbose_name=_("Decimal Places"),
|
|
)
|
|
prefix = models.CharField(max_length=10, verbose_name=_("Prefix"), blank=True)
|
|
suffix = models.CharField(max_length=10, verbose_name=_("Suffix"), blank=True)
|
|
|
|
exchange_currency = models.ForeignKey(
|
|
"self",
|
|
verbose_name=_("Exchange Currency"),
|
|
on_delete=models.SET_NULL,
|
|
related_name="exchange_currencies",
|
|
null=True,
|
|
blank=True,
|
|
help_text=_("Default currency for exchange calculations"),
|
|
)
|
|
show_exchanged_amounts = models.BooleanField(
|
|
default=True,
|
|
verbose_name=_("Show exchanged amounts"),
|
|
help_text=_(
|
|
"Show amounts converted to the exchange currency next to transactions and "
|
|
"totals in this currency. Accounts with their own exchange currency use "
|
|
"their own setting, and consolidated totals are calculated either way."
|
|
),
|
|
)
|
|
|
|
is_archived = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("Archived"),
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
verbose_name = _("Currency")
|
|
verbose_name_plural = _("Currencies")
|
|
ordering = ["name", "id"]
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
if self.exchange_currency == self:
|
|
raise ValidationError(
|
|
{
|
|
"exchange_currency": _(
|
|
"Currency cannot have itself as exchange currency."
|
|
)
|
|
}
|
|
)
|
|
|
|
|
|
class ExchangeRate(models.Model):
|
|
from_currency = models.ForeignKey(
|
|
Currency,
|
|
on_delete=models.CASCADE,
|
|
related_name="from_exchange_rates",
|
|
verbose_name=_("From Currency"),
|
|
)
|
|
to_currency = models.ForeignKey(
|
|
Currency,
|
|
on_delete=models.CASCADE,
|
|
related_name="to_exchange_rates",
|
|
verbose_name=_("To Currency"),
|
|
)
|
|
rate = models.DecimalField(
|
|
max_digits=42, decimal_places=30, verbose_name=_("Exchange Rate")
|
|
)
|
|
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
|
|
|
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
|
|
|
|
class Meta:
|
|
verbose_name = _("Exchange Rate")
|
|
verbose_name_plural = _("Exchange Rates")
|
|
unique_together = ("from_currency", "to_currency", "date")
|
|
|
|
def __str__(self):
|
|
return f"{self.from_currency.code} to {self.to_currency.code} on {self.date}"
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
# Check if the attributes exist before comparing them
|
|
if hasattr(self, "from_currency") and hasattr(self, "to_currency"):
|
|
if self.from_currency == self.to_currency:
|
|
raise ValidationError(
|
|
{"to_currency": _("From and To currencies cannot be the same.")}
|
|
)
|
|
|
|
|
|
class ExchangeRateService(models.Model):
|
|
"""Configuration for exchange rate services"""
|
|
|
|
class ServiceType(models.TextChoices):
|
|
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
|
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
|
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
|
FRANKFURTER = "frankfurter", "Frankfurter"
|
|
TWELVEDATA = "twelvedata", "TwelveData"
|
|
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
|
|
YFINANCE = "yfinance", "Yahoo Finance"
|
|
FXMACRODATA = "fxmacrodata", "FXMacroData"
|
|
ALLRATESTODAY = "allratestoday", "AllRatesToday Central Banks"
|
|
|
|
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")
|
|
)
|
|
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
|
|
api_key = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
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,
|
|
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")
|
|
)
|
|
|
|
failure_count = models.PositiveIntegerField(default=0)
|
|
|
|
target_currencies = models.ManyToManyField(
|
|
Currency,
|
|
verbose_name=_("Target Currencies"),
|
|
help_text=_(
|
|
"Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their set exchange currency."
|
|
),
|
|
related_name="exchange_services",
|
|
blank=True,
|
|
)
|
|
|
|
target_accounts = models.ManyToManyField(
|
|
"accounts.Account",
|
|
verbose_name=_("Target Accounts"),
|
|
help_text=_(
|
|
"Select accounts to fetch exchange rates for. Rates will be fetched for each account's currency against their set exchange currency."
|
|
),
|
|
related_name="exchange_services",
|
|
blank=True,
|
|
)
|
|
|
|
one_per_day = models.BooleanField(
|
|
verbose_name=_("One exchange rate per day"),
|
|
default=False,
|
|
help_text=_(
|
|
"Update the day's exchange rate on each fetch instead of adding a new one."
|
|
),
|
|
)
|
|
max_rates = models.PositiveIntegerField(
|
|
verbose_name=_("Exchange rates to keep"),
|
|
null=True,
|
|
blank=True,
|
|
validators=[MinValueValidator(1)],
|
|
help_text=_(
|
|
"Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all."
|
|
),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Exchange Rate Service")
|
|
verbose_name_plural = _("Exchange Rate Services")
|
|
ordering = ["name"]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def get_provider(self):
|
|
from apps.currencies.exchange_rates.fetcher import PROVIDER_MAPPING
|
|
|
|
provider_class = PROVIDER_MAPPING[self.service_type]
|
|
provider = provider_class(self.api_key)
|
|
provider.options = self.options or ""
|
|
return provider
|
|
|
|
@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:
|
|
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:
|
|
raise ValidationError(
|
|
{
|
|
"fetch_interval": _(
|
|
"Invalid format. Please check the requirements for your selected interval type."
|
|
)
|
|
}
|
|
)
|