Files
WYGIWYH/app/apps/currencies/models.py
T
Asish Kumar d3f29f5ad1 feat(currencies): make displaying exchanged amounts optional
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
2026-11-11 15:26:04 +05:30

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."
)
}
)