Compare commits

..

37 Commits
0.8.4 ... 0.9.0

Author SHA1 Message Date
Herculino Trotta
f788709f97 Merge pull request #140
automatic exchange rates
2025-02-07 11:49:25 -03:00
Herculino Trotta
1a0de32ef8 locale: update locales 2025-02-07 11:46:57 -03:00
Herculino Trotta
8315adeb4a fix(automatic-exchange-rates): 1-24 should be 0-23 2025-02-07 11:46:33 -03:00
Herculino Trotta
5296820d46 refactor(automatic-exchange-rates): replace fetch_interval with fetch interval type and fetch interval 2025-02-07 11:40:37 -03:00
Herculino Trotta
d5f5053821 Merge pull request #139 from eitchtee/dev
feat: cleanup and format logs
2025-02-07 11:31:40 -03:00
Herculino Trotta
852ffd5634 feat: cleanup and format logs 2025-02-07 11:31:14 -03:00
Herculino Trotta
8cb3f51ea4 Merge pull request #138
feat: add TZ env var
2025-02-07 11:29:48 -03:00
Herculino Trotta
62bfaaa62a feat: add TZ env var 2025-02-07 11:29:28 -03:00
Herculino Trotta
dd1d4292d3 Merge pull request #137
automatic_exchange_rate
2025-02-06 21:48:29 -03:00
Herculino Trotta
93bb34166e feat(ui): auto-resize textareas when typing 2025-02-06 21:40:04 -03:00
Herculino Trotta
8f311d9924 Add Unraid setup details 2025-02-05 15:24:00 -03:00
Herculino Trotta
a5a9f838f5 Merge pull request #135
fix(docker:single): procrastinate starts before django
2025-02-05 10:52:47 -03:00
Herculino Trotta
6c17b3babb fix(docker:single): procrastinate starts before django 2025-02-05 10:52:21 -03:00
Herculino Trotta
d207760ae9 feat(currencies): add automatic exchange rate fetching
Closes #123
2025-02-05 10:16:04 -03:00
Herculino Trotta
996e0ee0eb Merge pull request #133
fix(transactions): transaction convert value doesn't take into account currency's exchange currency
2025-02-03 00:30:42 -03:00
Herculino Trotta
80edf557cb fix(transactions): transaction convert value doesn't take into account currency's exchange currency
account takes precedence
2025-02-03 00:30:26 -03:00
Herculino Trotta
2f3207b1f6 Merge pull request #132 from eitchtee/dev
refactor(currencies): remove currency's code reference in the UI
2025-02-03 00:28:53 -03:00
Herculino Trotta
7b95c806fb refactor(currencies): remove currency's code reference in the UI 2025-02-03 00:28:21 -03:00
Herculino Trotta
06e9383689 Merge pull request #131
refactor(currencies): make currency code non-unique and increase it's size
2025-02-03 00:27:31 -03:00
Herculino Trotta
56862cd025 refactor(currencies): make currency code non-unique and increase it's size 2025-02-03 00:27:11 -03:00
Herculino Trotta
35782cf14c Merge pull request #130
feat: internal code for automatic exchange rate fetching
2025-02-03 00:26:19 -03:00
Herculino Trotta
f7768c8658 feat: internal code for automatic exchange rate fetching 2025-02-03 00:26:00 -03:00
Herculino Trotta
7f8fe6a516 Merge pull request #129
fix: unable to display exchange projected income value
2025-02-03 00:20:15 -03:00
Herculino Trotta
aa8abe0e1c fix: unable to display exchange projected income value 2025-02-03 00:20:00 -03:00
Herculino Trotta
3190f3ae09 Merge pull request #128
fix: changing startpage to networth breaks homepage
2025-02-02 00:05:19 -03:00
Herculino Trotta
757f6647da fix: changing startpage to networth breaks homepage 2025-02-02 00:04:45 -03:00
Herculino Trotta
6721d9dfee Merge pull request #127
feat: indicate what paid/project button means
2025-02-01 19:06:23 -03:00
Herculino Trotta
9705441e2d feat: indicate what paid/project button means
Closes #122
2025-02-01 19:06:04 -03:00
Herculino Trotta
7123aefad0 Merge pull request #126 from eitchtee/dev
feat: indicate what paid/project button means
2025-02-01 15:05:26 -03:00
Herculino Trotta
712f5f428e feat: indicate what paid/project button means 2025-02-01 15:04:58 -03:00
Herculino Trotta
a2e97b4ba2 Merge pull request #125
fix: changing startpage from monthly breaks homepage
2025-02-01 15:00:22 -03:00
Herculino Trotta
60a694635b fix: changing startpage from monthly breaks homepage
Fixes #121
2025-02-01 14:59:55 -03:00
Herculino Trotta
877816b649 Merge pull request #120
feat: add trash can to see deleted transactions
2025-02-01 11:13:18 -03:00
Herculino Trotta
0a3e47819a feat: add trash can to see deleted transactions 2025-02-01 11:12:43 -03:00
Herculino Trotta
f9d299cb78 refactor: remove single 2025-02-01 09:43:48 -03:00
Herculino Trotta
52934124c1 Merge pull request #118 from eitchtee/dev
feat: add account and currency info to monthly view
2025-02-01 00:51:41 -03:00
Herculino Trotta
39c1f634b6 feat: add account and currency info to monthly view 2025-02-01 00:51:16 -03:00
75 changed files with 4356 additions and 2005 deletions

View File

@@ -1,6 +1,8 @@
SERVER_NAME=wygiwyh_server
DB_NAME=wygiwyh_pg
TZ=UTC # Change to your timezone. This only affects some async tasks.
DEBUG=false
URL = https://...
HTTPS_ENABLED=true

View File

@@ -79,6 +79,9 @@ $ docker compose up -d
$ docker compose exec -it web python manage.py createsuperuser
```
> [!NOTE]
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Enviroment Variables](#enviroment-variables) for an explanation of all available variables
## Running locally
If you want to run WYGIWYH locally, on your env file:
@@ -105,6 +108,8 @@ All the required Dockerfiles are [here](https://github.com/eitchtee/WYGIWYH/tree
WYGIWYH is available on the Unraid Store. You'll need to provision your own postgres (version 15 or up) database.
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
## Enviroment Variables
| variable | type | default | explanation |

View File

@@ -0,0 +1,19 @@
import logging
class ProcrastinateFilter(logging.Filter):
# from https://github.com/madzak/python-json-logger/blob/master/src/pythonjsonlogger/jsonlogger.py#L19
_reserved_log_keys = frozenset(
"""args asctime created exc_info exc_text filename
funcName levelname levelno lineno module msecs message msg name pathname
process processName relativeCreated stack_info thread threadName""".split()
)
def filter(self, record: logging.LogRecord):
record.procrastinate = {}
for key, value in vars(record).items():
if not key.startswith("_") and key not in self._reserved_log_keys | {
"procrastinate"
}:
record.procrastinate[key] = value # type: ignore
return True

View File

View File

@@ -166,7 +166,7 @@ LANGUAGES = (
("pt-br", "Português (Brasil)"),
)
TIME_ZONE = "UTC"
TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True
@@ -278,28 +278,42 @@ if "procrastinate" in sys.argv:
"disable_existing_loggers": False,
"formatters": {
"procrastinate": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s -> %(procrastinate)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"standard": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"procrastinate": {
"()": "WYGIWYH.logs.ProcrastinateFilter.ProcrastinateFilter",
"name": "procrastinate",
},
},
"handlers": {
"procrastinate": {
"level": "DEBUG",
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
"filters": ["procrastinate"],
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
},
"loggers": {
"procrastinate": {
"handlers": ["procrastinate"],
"level": "INFO",
"propagate": False,
"propagate": True,
},
"root": {
"handlers": ["console"],
"handlers": None,
"level": "INFO",
"propagate": False,
},
},
}
@@ -308,24 +322,25 @@ else:
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"procrastinate": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s"
"standard": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"procrastinate": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
"procrastinate": {
"level": "INFO",
"class": "logging.StreamHandler",
},
},
"loggers": {
"procrastinate": {
"handlers": None,
"level": "INFO",
"propagate": False,
},
"root": {

View File

@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 4 * * *")
@app.task(queueing_lock="remove_old_jobs", pass_context=True)
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
async def remove_old_jobs(context, timestamp):
try:
return await builtin_tasks.remove_old_jobs(
@@ -30,7 +30,7 @@ async def remove_old_jobs(context, timestamp):
@app.periodic(cron="0 6 1 * *")
@app.task(queueing_lock="remove_expired_sessions")
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
async def remove_expired_sessions(timestamp=None):
"""Cleanup expired sessions by using Django management command."""
try:

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from apps.currencies.models import Currency, ExchangeRate
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
@admin.register(Currency)
@@ -11,4 +11,19 @@ class CurrencyAdmin(admin.ModelAdmin):
return super().formfield_for_dbfield(db_field, request, **kwargs)
@admin.register(ExchangeRateService)
class ExchangeRateServiceAdmin(admin.ModelAdmin):
list_display = [
"name",
"service_type",
"is_active",
"interval_type",
"fetch_interval",
"last_fetch",
]
list_filter = ["is_active", "service_type"]
search_fields = ["name"]
filter_horizontal = ["target_currencies"]
admin.site.register(ExchangeRate)

View File

@@ -0,0 +1,30 @@
from abc import ABC, abstractmethod
from decimal import Decimal
from typing import List, Tuple, Optional
from django.db.models import QuerySet
from apps.currencies.models import Currency
class ExchangeRateProvider(ABC):
rates_inverted = False
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key
@abstractmethod
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
"""Fetch exchange rates for multiple currency pairs"""
raise NotImplementedError("Subclasses must implement get_rates method")
@classmethod
def requires_api_key(cls) -> bool:
"""Return True if the service requires an API key"""
return True
@staticmethod
def invert_rate(rate: Decimal) -> Decimal:
"""Invert the given rate."""
return Decimal("1") / rate

View File

@@ -0,0 +1,206 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
from apps.currencies.exchange_rates.providers import (
SynthFinanceProvider,
CoinGeckoFreeProvider,
CoinGeckoProProvider,
)
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
logger = logging.getLogger(__name__)
# Map service types to provider classes
PROVIDER_MAPPING = {
"synth_finance": SynthFinanceProvider,
"coingecko_free": CoinGeckoFreeProvider,
"coingecko_pro": CoinGeckoProProvider,
}
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 schedule.
"""
services = ExchangeRateService.objects.filter(is_active=True)
current_time = timezone.now().astimezone()
current_hour = current_time.hour
for service in services:
try:
if force:
logger.info(f"Force fetching rates for {service.name}")
ExchangeRateFetcher._fetch_service_rates(service)
continue
# 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 type: {service.interval_type}, "
f"Current hour: {current_hour}"
)
ExchangeRateFetcher._fetch_service_rates(service)
else:
logger.debug(
f"Skipping {service.name}. "
f"Current hour: {current_hour}, "
f"Interval type: {service.interval_type}, "
f"Fetch interval: {service.fetch_interval}"
)
except Exception as e:
logger.error(f"Error checking fetch schedule for {service.name}: {e}")
@staticmethod
def _get_unique_currency_pairs(
service: ExchangeRateService,
) -> tuple[QuerySet, set]:
"""
Get unique currency pairs from both target_currencies and target_accounts
Returns a tuple of (target_currencies QuerySet, exchange_currencies set)
"""
# Get currencies from target_currencies
target_currencies = set(service.target_currencies.all())
# Add currencies from target_accounts
for account in service.target_accounts.all():
if account.currency and account.exchange_currency:
target_currencies.add(account.currency)
# Convert back to QuerySet for compatibility with existing code
target_currencies_qs = Currency.objects.filter(
id__in=[curr.id for curr in target_currencies]
)
# Get unique exchange currencies
exchange_currencies = set()
# From target_currencies
for currency in target_currencies:
if currency.exchange_currency:
exchange_currencies.add(currency.exchange_currency)
# From target_accounts
for account in service.target_accounts.all():
if account.exchange_currency:
exchange_currencies.add(account.exchange_currency)
return target_currencies_qs, exchange_currencies
@staticmethod
def _fetch_service_rates(service: ExchangeRateService) -> None:
"""Fetch rates for a specific service"""
try:
provider = service.get_provider()
# Check if API key is required but missing
if provider.requires_api_key() and not service.api_key:
logger.error(f"API key required but not provided for {service.name}")
return
# Get unique currency pairs from both sources
target_currencies, exchange_currencies = (
ExchangeRateFetcher._get_unique_currency_pairs(service)
)
# Skip if no currencies to process
if not target_currencies or not exchange_currencies:
logger.info(f"No currency pairs to process for service {service.name}")
return
rates = provider.get_rates(target_currencies, exchange_currencies)
# Track processed currency pairs to avoid duplicates
processed_pairs = set()
for from_currency, to_currency, rate in rates:
# Create a unique identifier for this currency pair
pair_key = (from_currency.id, to_currency.id)
if pair_key in processed_pairs:
continue
if provider.rates_inverted:
# If rates are inverted, we need to swap currencies
ExchangeRate.objects.create(
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
ExchangeRate.objects.create(
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()
service.save()
except Exception as e:
logger.error(f"Error fetching rates for {service.name}: {e}")

View File

@@ -0,0 +1,152 @@
import logging
import time
import requests
from decimal import Decimal
from typing import Tuple, List
from django.db.models import QuerySet
from apps.currencies.models import Currency
from apps.currencies.exchange_rates.base import ExchangeRateProvider
logger = logging.getLogger(__name__)
class SynthFinanceProvider(ExchangeRateProvider):
"""Implementation for Synth Finance API (synthfinance.com)"""
BASE_URL = "https://api.synthfinance.com/rates/live"
rates_inverted = False # SynthFinance returns non-inverted rates
def __init__(self, api_key: str = None):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {self.api_key}"})
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
currency_groups = {}
for currency in target_currencies:
if currency.exchange_currency in exchange_currencies:
group = currency_groups.setdefault(currency.exchange_currency.code, [])
group.append(currency)
for base_currency, currencies in currency_groups.items():
try:
to_currencies = ",".join(
currency.code
for currency in currencies
if currency.code != base_currency
)
response = self.session.get(
f"{self.BASE_URL}",
params={"from": base_currency, "to": to_currencies},
)
response.raise_for_status()
data = response.json()
rates = data["data"]["rates"]
for currency in currencies:
if currency.code == base_currency:
rate = Decimal("1")
else:
rate = Decimal(str(rates[currency.code]))
# Return the rate as is, without inversion
results.append((currency.exchange_currency, currency, rate))
credits_used = data["meta"]["credits_used"]
credits_remaining = data["meta"]["credits_remaining"]
logger.info(
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
)
except requests.RequestException as e:
logger.error(
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
)
except KeyError as e:
logger.error(
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
)
except Exception as e:
logger.error(
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
)
return results
class CoinGeckoFreeProvider(ExchangeRateProvider):
"""Implementation for CoinGecko Free API"""
BASE_URL = "https://api.coingecko.com/api/v3"
rates_inverted = True
def __init__(self, api_key: str):
super().__init__(api_key)
self.session = requests.Session()
self.session.headers.update({"x-cg-demo-api-key": api_key})
@classmethod
def requires_api_key(cls) -> bool:
return True
def get_rates(
self, target_currencies: QuerySet, exchange_currencies: set
) -> List[Tuple[Currency, Currency, Decimal]]:
results = []
all_currencies = set(currency.code.lower() for currency in target_currencies)
all_currencies.update(currency.code.lower() for currency in exchange_currencies)
try:
response = self.session.get(
f"{self.BASE_URL}/simple/price",
params={
"ids": ",".join(all_currencies),
"vs_currencies": ",".join(all_currencies),
},
)
response.raise_for_status()
rates_data = response.json()
for target_currency in target_currencies:
if target_currency.exchange_currency in exchange_currencies:
try:
rate = Decimal(
str(
rates_data[target_currency.code.lower()][
target_currency.exchange_currency.code.lower()
]
)
)
# The rate is already inverted, so we don't need to invert it again
results.append(
(target_currency.exchange_currency, target_currency, rate)
)
except KeyError:
logger.error(
f"Rate not found for {target_currency.code} or {target_currency.exchange_currency.code}"
)
except Exception as e:
logger.error(
f"Error calculating rate for {target_currency.code}: {e}"
)
time.sleep(1) # CoinGecko allows 10-30 calls/minute for free tier
except requests.RequestException as e:
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})

View File

@@ -1,6 +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 _
@@ -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,54 @@ class ExchangeRateForm(forms.ModelForm):
),
),
)
class ExchangeRateServiceForm(forms.ModelForm):
class Meta:
model = ExchangeRateService
fields = [
"name",
"service_type",
"is_active",
"api_key",
"interval_type",
"fetch_interval",
"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",
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",
)
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"
),
),
)

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.1.5 on 2025-02-02 20:35
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0006_currency_exchange_currency'),
]
operations = [
migrations.CreateModel(
name='ExchangeRateService',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='Service Name')),
('service_type', models.CharField(choices=[('synth_finance', 'Synth Finance'), ('coingecko', 'CoinGecko')], max_length=255, verbose_name='Service Type')),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('api_key', models.CharField(blank=True, help_text='API key for the service (if required)', max_length=255, null=True, verbose_name='API Key')),
('fetch_interval_hours', models.PositiveIntegerField(default=24, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Fetch Interval (hours)')),
('last_fetch', models.DateTimeField(blank=True, null=True, verbose_name='Last Successful Fetch')),
('target_currencies', models.ManyToManyField(help_text='Select currencies to fetch exchange rates for. Rates will be fetched for each currency against their exchange_currency.', related_name='exchange_services', to='currencies.currency', verbose_name='Target Currencies')),
],
options={
'verbose_name': 'Exchange Rate Service',
'verbose_name_plural': 'Exchange Rate Services',
'ordering': ['name'],
},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.5 on 2025-02-03 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_account_name'),
('currencies', '0007_exchangerateservice'),
]
operations = [
migrations.AddField(
model_name='exchangerateservice',
name='target_accounts',
field=models.ManyToManyField(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', to='accounts.account', verbose_name='Target Accounts'),
),
migrations.AlterField(
model_name='exchangerateservice',
name='target_currencies',
field=models.ManyToManyField(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', to='currencies.currency', verbose_name='Target Currencies'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.5 on 2025-02-03 01:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_alter_account_name'),
('currencies', '0008_exchangerateservice_target_accounts_and_more'),
]
operations = [
migrations.AlterField(
model_name='exchangerateservice',
name='target_accounts',
field=models.ManyToManyField(blank=True, 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', to='accounts.account', verbose_name='Target Accounts'),
),
migrations.AlterField(
model_name='exchangerateservice',
name='target_currencies',
field=models.ManyToManyField(blank=True, 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', to='currencies.currency', verbose_name='Target Currencies'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-03 03:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0009_alter_exchangerateservice_target_accounts_and_more'),
]
operations = [
migrations.AlterField(
model_name='currency',
name='code',
field=models.CharField(max_length=255, verbose_name='Currency Code'),
),
]

View File

@@ -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'),
),
]

View File

@@ -1,11 +1,18 @@
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=10, unique=True, verbose_name=_("Currency Code"))
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,
@@ -78,3 +85,155 @@ class ExchangeRate(models.Model):
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):
SYNTH_FINANCE = "synth_finance", "Synth Finance"
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")
)
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)"),
)
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")
)
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,
)
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]
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 < 0 or hours > 23:
raise ValidationError(
{
"fetch_interval": _(
"'Every X hours' interval must be between 0 and 23."
)
}
)
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."
)
}
)

View File

@@ -0,0 +1,30 @@
import logging
from procrastinate.contrib.django import app
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
logger = logging.getLogger(__name__)
@app.periodic(cron="0 * * * *") # Run every hour
@app.task(name="automatic_fetch_exchange_rates")
def automatic_fetch_exchange_rates(timestamp=None):
"""Fetch exchange rates for all due services"""
fetcher = ExchangeRateFetcher()
try:
fetcher.fetch_due_rates()
except Exception as e:
logger.error(e, exc_info=True)
@app.task(name="manual_fetch_exchange_rates")
def manual_fetch_exchange_rates(timestamp=None):
"""Fetch exchange rates for all due services"""
fetcher = ExchangeRateFetcher()
try:
fetcher.fetch_due_rates(force=True)
except Exception as e:
logger.error(e, exc_info=True)

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",
),
]

View File

@@ -1,2 +1,3 @@
from .currencies import *
from .exchange_rates import *
from .exchange_rates_services import *

View File

@@ -27,17 +27,17 @@ def exchange_rates_index(request):
@require_http_methods(["GET"])
def exchange_rates_list(request):
pairings = (
ExchangeRate.objects.values("from_currency__code", "to_currency__code")
ExchangeRate.objects.values("from_currency__name", "to_currency__name")
.distinct()
.annotate(
pair=Concat(
"from_currency__code",
"from_currency__name",
Value(" x "),
"to_currency__code",
"to_currency__name",
output_field=CharField(),
)
)
.values_list("pair", "from_currency__code", "to_currency__code")
.values_list("pair", "from_currency__name", "to_currency__name")
)
return render(
@@ -56,7 +56,7 @@ def exchange_rates_list_pair(request):
if from_currency and to_currency:
exchange_rates = ExchangeRate.objects.filter(
from_currency__code=from_currency, to_currency__code=to_currency
from_currency__name=from_currency, to_currency__name=to_currency
).order_by("-date")
else:
exchange_rates = ExchangeRate.objects.all().order_by("-date")

View File

@@ -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",
},
)

View File

@@ -9,7 +9,7 @@ from apps.import_app.services import ImportServiceV1
logger = logging.getLogger(__name__)
@app.task
@app.task(name="process_import")
def process_import(import_run_id: int, file_path: str):
try:
import_run = ImportRun.objects.get(id=import_run_id)

View File

@@ -19,4 +19,19 @@ urlpatterns = [
views.monthly_summary,
name="monthly_summary",
),
path(
"monthly/<int:month>/<int:year>/summary/accounts/",
views.monthly_account_summary,
name="monthly_account_summary",
),
path(
"monthly/<int:month>/<int:year>/summary/currencies/",
views.monthly_currency_summary,
name="monthly_currency_summary",
),
path(
"monthly/summary/select/<str:selected>/",
views.monthly_summary_select,
name="monthly_summary_select",
),
]

View File

@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import (
Q,
)
from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@@ -16,6 +17,7 @@ from apps.transactions.models import Transaction
from apps.transactions.utils.calculations import (
calculate_currency_totals,
calculate_percentage_distribution,
calculate_account_totals,
)
from apps.transactions.utils.default_ordering import default_order
@@ -31,6 +33,7 @@ def index(request):
@require_http_methods(["GET"])
def monthly_overview(request, month: int, year: int):
order = request.session.get("monthly_transactions_order", "default")
summary_tab = request.session.get("monthly_summary_tab", "summary")
if month < 1 or month > 12:
from django.http import Http404
@@ -57,6 +60,7 @@ def monthly_overview(request, month: int, year: int):
"previous_year": previous_year,
"filter": f,
"order": order,
"summary_tab": summary_tab,
},
)
@@ -131,3 +135,61 @@ def monthly_summary(request, month: int, year: int):
"monthly_overview/fragments/monthly_summary.html",
context=context,
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def monthly_account_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(category__mute=True) & ~Q(category=None))
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
account_percentages = calculate_percentage_distribution(account_data)
context = {
"account_data": account_data,
"account_percentages": account_percentages,
}
return render(
request,
"monthly_overview/fragments/monthly_account_summary.html",
context=context,
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def monthly_currency_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(category__mute=True) & ~Q(category=None))
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)
context = {
"currency_data": currency_data,
"currency_percentages": currency_percentages,
}
return render(
request, "monthly_overview/fragments/monthly_currency_summary.html", context
)
@login_required
@require_http_methods(["GET"])
def monthly_summary_select(request, selected):
request.session["monthly_summary_tab"] = selected
return HttpResponse(
status=204,
)

View File

@@ -18,7 +18,7 @@ from apps.transactions.models import (
logger = logging.getLogger(__name__)
@app.task
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id: int,
signal,

View File

@@ -228,9 +228,12 @@ class Transaction(models.Model):
def delete(self, *args, **kwargs):
if settings.ENABLE_SOFT_DELETE:
self.deleted = True
self.deleted_at = timezone.now()
self.save()
if not self.deleted:
self.deleted = True
self.deleted_at = timezone.now()
self.save()
else:
super().delete(*args, **kwargs)
else:
super().delete(*args, **kwargs)
@@ -252,6 +255,20 @@ class Transaction(models.Model):
"suffix": suffix,
"decimal_places": decimal_places,
}
elif self.account.currency.exchange_currency:
converted_amount, prefix, suffix, decimal_places = convert(
self.amount,
to_currency=self.account.currency.exchange_currency,
from_currency=self.account.currency,
date=self.date,
)
if converted_amount:
return {
"amount": converted_amount,
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
}
return None

View File

@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 0 * * *")
@app.task
@app.task(name="generate_recurring_transactions")
def generate_recurring_transactions(timestamp=None):
try:
RecurringTransaction.generate_upcoming_transactions()
@@ -26,7 +26,7 @@ def generate_recurring_transactions(timestamp=None):
@app.periodic(cron="10 1 * * *")
@app.task
@app.task(name="cleanup_deleted_transactions")
def cleanup_deleted_transactions(timestamp=None):
with cachalot_disabled():
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:

View File

@@ -6,11 +6,36 @@ urlpatterns = [
path(
"transactions/list/", views.transaction_all_list, name="transactions_all_list"
),
path(
"transactions/trash/",
views.transactions_trash_can_index,
name="transactions_trash_index",
),
path(
"transactions/trash/list/",
views.transactions_trash_can_list,
name="transactions_trash_list",
),
path(
"transactions/summary/",
views.transaction_all_summary,
name="transactions_all_summary",
),
path(
"transactions/summary/account/",
views.transaction_all_account_summary,
name="transaction_all_account_summary",
),
path(
"transactions/summary/currency/",
views.transaction_all_currency_summary,
name="transaction_all_currency_summary",
),
path(
"transactions/summary/select/<str:selected>/",
views.transaction_all_summary_select,
name="transaction_all_summary_select",
),
path(
"transactions/actions/pay/",
views.bulk_pay_transactions,
@@ -26,6 +51,11 @@ urlpatterns = [
views.bulk_delete_transactions,
name="transactions_bulk_delete",
),
path(
"transactions/actions/undelete/",
views.bulk_undelete_transactions,
name="transactions_bulk_undelete",
),
path(
"transactions/actions/duplicate/",
views.bulk_clone_transactions,
@@ -41,6 +71,11 @@ urlpatterns = [
views.transaction_delete,
name="transaction_delete",
),
path(
"transaction/<int:transaction_id>/undelete/",
views.transaction_undelete,
name="transaction_undelete",
),
path(
"transaction/<int:transaction_id>/edit/",
views.transaction_edit,

View File

@@ -72,8 +72,12 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
.order_by()
)
# Process the results and calculate additional totals
# First pass: Process basic totals and store all currency data
result = {}
currencies_using_exchange = (
{}
) # Track which currencies use which exchange currencies
for total in currency_totals:
# Skip empty currencies if ignore_empty is True
if ignore_empty and all(
@@ -91,7 +95,6 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
total_current = total["income_current"] - total["expense_current"]
total_projected = total["income_projected"] - total["expense_projected"]
total_final = total_current + total_projected
currency_id = total["account__currency"]
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = (
@@ -120,8 +123,6 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
# Add exchanged values if exchange_currency exists
if exchange_currency:
exchanged = {}
# Convert each value
for field in [
"expense_current",
"expense_projected",
@@ -136,7 +137,6 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
@@ -148,12 +148,48 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
"name": exchange_currency.name,
}
# Only add exchanged data if at least one conversion was successful
if exchanged:
currency_data["exchanged"] = exchanged
# Track which currencies are using which exchange currencies
if exchange_currency.id not in currencies_using_exchange:
currencies_using_exchange[exchange_currency.id] = []
currencies_using_exchange[exchange_currency.id].append(
{"currency_id": currency_id, "exchanged": exchanged}
)
result[currency_id] = currency_data
# Second pass: Add consolidated totals for currencies that are used as exchange currencies
for currency_id, currency_data in result.items():
if currency_id in currencies_using_exchange:
consolidated = {
"currency": currency_data["currency"].copy(),
"expense_current": currency_data["expense_current"],
"expense_projected": currency_data["expense_projected"],
"income_current": currency_data["income_current"],
"income_projected": currency_data["income_projected"],
"total_current": currency_data["total_current"],
"total_projected": currency_data["total_projected"],
"total_final": currency_data["total_final"],
}
# Add exchanged values from all currencies using this as exchange currency
for using_currency in currencies_using_exchange[currency_id]:
exchanged = using_currency["exchanged"]
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_current",
"total_projected",
"total_final",
]:
if field in exchanged:
consolidated[field] += exchanged[field]
result[currency_id]["consolidated"] = consolidated
return result

View File

@@ -61,7 +61,7 @@ def bulk_unpay_transactions(request):
@login_required
def bulk_delete_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.objects.filter(id__in=selected_transactions)
transactions = Transaction.all_objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.delete()
@@ -81,6 +81,30 @@ def bulk_delete_transactions(request):
)
@only_htmx
@login_required
def bulk_undelete_transactions(request):
selected_transactions = request.GET.getlist("transactions", [])
transactions = Transaction.deleted_objects.filter(id__in=selected_transactions)
count = transactions.count()
transactions.update(deleted=False, deleted_at=None)
messages.success(
request,
ngettext_lazy(
"%(count)s transaction restored successfully",
"%(count)s transactions restored successfully",
count,
)
% {"count": count},
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
def bulk_clone_transactions(request):

View File

@@ -244,7 +244,7 @@ def transaction_clone(request, transaction_id, **kwargs):
@login_required
@require_http_methods(["DELETE"])
def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id)
transaction = get_object_or_404(Transaction.all_objects, id=transaction_id)
transaction.delete()
@@ -256,6 +256,24 @@ def transaction_delete(request, transaction_id, **kwargs):
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_undelete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction.deleted_objects, id=transaction_id)
transaction.deleted = False
transaction.deleted_at = None
transaction.save()
messages.success(request, _("Transaction restored successfully"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated"},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@@ -314,9 +332,13 @@ def transaction_pay(request, transaction_id):
@require_http_methods(["GET"])
def transaction_all_index(request):
order = request.session.get("all_transactions_order", "default")
summary_tab = request.session.get("transaction_all_summary_tab", "currency")
f = TransactionsFilter(request.GET)
return render(
request, "transactions/pages/transactions.html", {"filter": f, "order": order}
request,
"transactions/pages/transactions.html",
{"filter": f, "order": order, "summary_tab": summary_tab},
)
@@ -382,16 +404,98 @@ def transaction_all_summary(request):
account_percentages = calculate_percentage_distribution(account_data)
context = {
"income_current": remove_falsey_entries(currency_data, "income_current"),
"income_projected": remove_falsey_entries(currency_data, "income_projected"),
"expense_current": remove_falsey_entries(currency_data, "expense_current"),
"expense_projected": remove_falsey_entries(currency_data, "expense_projected"),
"total_current": remove_falsey_entries(currency_data, "total_current"),
"total_final": remove_falsey_entries(currency_data, "total_final"),
"total_projected": remove_falsey_entries(currency_data, "total_projected"),
"currency_data": currency_data,
"currency_percentages": currency_percentages,
"account_data": account_data,
"account_percentages": account_percentages,
}
return render(request, "transactions/fragments/summary.html", context)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_all_account_summary(request):
transactions = Transaction.objects.prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
).all()
f = TransactionsFilter(request.GET, queryset=transactions)
account_data = calculate_account_totals(transactions_queryset=f.qs.all())
account_percentages = calculate_percentage_distribution(account_data)
context = {
"account_data": account_data,
"account_percentages": account_percentages,
}
return render(request, "transactions/fragments/all_account_summary.html", context)
@only_htmx
@login_required
@require_http_methods(["GET"])
def transaction_all_currency_summary(request):
transactions = Transaction.objects.prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
).all()
f = TransactionsFilter(request.GET, queryset=transactions)
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)
context = {
"currency_data": currency_data,
"currency_percentages": currency_percentages,
}
return render(request, "transactions/fragments/all_currency_summary.html", context)
@login_required
@require_http_methods(["GET"])
def transaction_all_summary_select(request, selected):
request.session["transaction_all_summary_tab"] = selected
return HttpResponse(
status=204,
)
@login_required
@require_http_methods(["GET"])
def transactions_trash_can_index(request):
return render(request, "transactions/pages/trash.html")
def transactions_trash_can_list(request):
transactions = Transaction.deleted_objects.prefetch_related(
"account",
"account__group",
"category",
"tags",
"account__exchange_currency",
"account__currency",
"installment_plan",
).all()
return render(
request,
"transactions/fragments/trash_list.html",
{"transactions": transactions},
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 02:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0017_usersettings_number_format'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='start_page',
field=models.CharField(choices=[('MONTHLY_OVERVIEW', 'Monthly'), ('YEARLY_OVERVIEW_CURRENCY', 'Yearly by currency'), ('YEARLY_OVERVIEW_ACCOUNT', 'Yearly by account'), ('NETWORTH_CURRENT', 'Current Net Worth'), ('NETWORTH_PROJECTED', 'Projected Net Worth'), ('ALL_TRANSACTIONS', 'All Transactions'), ('CALENDAR', 'Calendar')], default='MONTHLY_OVERVIEW', max_length=255, verbose_name='Start page'),
),
]

View File

@@ -26,7 +26,8 @@ class UserSettings(models.Model):
MONTHLY = "MONTHLY_OVERVIEW", _("Monthly")
YEARLY_CURRENCY = "YEARLY_OVERVIEW_CURRENCY", _("Yearly by currency")
YEARLY_ACCOUNT = "YEARLY_OVERVIEW_ACCOUNT", _("Yearly by account")
NETWORTH = "NETWORTH", _("Net Worth")
NETWORTH_CURRENT = "NETWORTH_CURRENT", _("Current Net Worth")
NETWORTH_PROJECTED = "NETWORTH_PROJECTED", _("Projected Net Worth")
ALL_TRANSACTIONS = "ALL_TRANSACTIONS", _("All Transactions")
CALENDAR = "CALENDAR", _("Calendar")

View File

@@ -26,10 +26,14 @@ def logout_view(request):
def index(request):
if request.user.settings.start_page == UserSettings.StartPage.MONTHLY:
return redirect(reverse("monthly_index"))
elif request.user.settings.start_page == UserSettings.StartPage.YEARLY:
return redirect(reverse("yearly_index"))
elif request.user.settings.start_page == UserSettings.StartPage.NETWORTH:
return redirect(reverse("net_worth"))
elif request.user.settings.start_page == UserSettings.StartPage.YEARLY_ACCOUNT:
return redirect(reverse("yearly_index_account"))
elif request.user.settings.start_page == UserSettings.StartPage.YEARLY_CURRENCY:
return redirect(reverse("yearly_index_currency"))
elif request.user.settings.start_page == UserSettings.StartPage.NETWORTH_CURRENT:
return redirect(reverse("net_worth_current"))
elif request.user.settings.start_page == UserSettings.StartPage.NETWORTH_PROJECTED:
return redirect(reverse("net_worth_projected"))
elif request.user.settings.start_page == UserSettings.StartPage.ALL_TRANSACTIONS:
return redirect(reverse("transactions_all_index"))
elif request.user.settings.start_page == UserSettings.StartPage.CALENDAR:

View File

@@ -89,7 +89,6 @@ def yearly_overview_by_currency(request, year: int):
"year": year,
"totals": data,
"percentages": percentages,
"single": True if currency else False,
},
)
@@ -159,6 +158,5 @@ def yearly_overview_by_account(request, year: int):
"year": year,
"totals": data,
"percentages": percentages,
"single": True if account else False,
},
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,83 +2,95 @@
{% load i18n %}
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
</label>
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
</label>
{% endif %}
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
hover:tw-bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw-border-dashed{% else %}tw-border-solid{% endif %}
{% if transaction.type == "EX" %}tw-border-red-500{% else %}tw-border-green-500{% endif %} tw-relative
w-100 transaction-item"
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
on mouseout add .tw-invisible to the first .transaction-actions in me end">
<div class="row font-monospace tw-text-sm align-items-center">
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center">
<a class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
role="button"
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
hx-target="closest .transaction"
hx-swap="outerHTML">
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center">
{% if not transaction.deleted %}
<a class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
role="button"
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
hx-target="closest .transaction"
hx-swap="outerHTML">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
</a>
</div>
<div class="col-lg-8 col-12">
{# Date#}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
</a>
{% else %}
<div class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-8 col-12">
{# Date#}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
<div
class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
</div>
{# Description#}
<div class="mb-2 mb-lg-1 text-white tw-text-base">
{% spaceless %}
<span>{{ transaction.description }}</span>
{% if transaction.installment_plan and transaction.installment_id %}
<span
class="badge text-bg-secondary ms-2">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
{% endif %}
{% if transaction.recurring_transaction %}
<span class="text-primary tw-text-xs ms-2"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
{% endif %}
{% endspaceless %}
</div>
<div class="tw-text-gray-400 tw-text-sm">
{# Entities #}
{% with transaction.entities.all as entities %}
{% if entities %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ entities|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
{# Notes#}
{% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
</div>
{% endif %}
{# Category#}
{% if transaction.category %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.category.name }}</div>
</div>
{% endif %}
{# Tags#}
{% with transaction.tags.all as tags %}
{% if tags %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ tags|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
</div>
</div>
{# Description#}
<div class="mb-2 mb-lg-1 text-white tw-text-base">
{% spaceless %}
<span>{{ transaction.description }}</span>
{% if transaction.installment_plan and transaction.installment_id %}
<span class="badge text-bg-secondary ms-2">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
{% endif %}
{% if transaction.recurring_transaction %}
<span class="text-primary tw-text-xs ms-2"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
{% endif %}
{% endspaceless %}
</div>
<div class="tw-text-gray-400 tw-text-sm">
{# Entities #}
{% with transaction.entities.all as entities %}
{% if entities %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ entities|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
{# Notes#}
{% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
</div>
{% endif %}
{# Category#}
{% if transaction.category %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.category.name }}</div>
</div>
{% endif %}
{# Tags#}
{% with transaction.tags.all as tags %}
{% if tags %}
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ tags|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
</div>
</div>
<div class="col-lg-3 col-12 text-lg-end align-self-end">
<div class="col-lg-3 col-12 text-lg-end align-self-end">
<div class="main-amount mb-2 mb-lg-0">
<c-amount.display
<c-amount.display
:amount="transaction.amount"
:prefix="transaction.account.currency.prefix"
:suffix="transaction.account.currency.suffix"
@@ -87,53 +99,77 @@
</div>
{# Exchange Rate#}
{% with exchanged=transaction.exchanged_amount %}
{% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0">
<c-amount.display
:amount="exchanged.amount"
:prefix="exchanged.prefix"
:suffix="exchanged.suffix"
:decimal_places="exchanged.decimal_places"
color="grey"></c-amount.display>
</div>
{% endif %}
{% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0">
<c-amount.display
:amount="exchanged.amount"
:prefix="exchanged.prefix"
:suffix="exchanged.suffix"
:decimal_places="exchanged.decimal_places"
color="grey"></c-amount.display>
</div>
{% endif %}
{% endwith %}
<div>{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}</div>
</div>
<div>
{# Item actions#}
<div class="transaction-actions !tw-absolute tw-left-1/2 tw-top-0 tw--translate-x-1/2 tw--translate-y-1/2 tw-invisible d-flex flex-row card">
<div class="card-body p-1 shadow-lg">
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Duplicate" %}"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
hx-trigger="ready" >
<i class="fa-solid fa-clone fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
<div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
</div>
</div>
<div>
{# Item actions#}
<div
class="transaction-actions !tw-absolute tw-left-1/2 tw-top-0 tw--translate-x-1/2 tw--translate-y-1/2 tw-invisible d-flex flex-row card">
<div class="card-body p-1 shadow-lg">
{% if not transaction.deleted %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Duplicate" %}"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
hx-trigger="ready">
<i class="fa-solid fa-clone fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% else %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Restore" %}"
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
class="fa-solid fa-trash-arrow-up"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,184 @@
{% load tools %}
{% load i18n %}
<div class="col card shadow">
<div class="card-body">
{% if account.account.group %}
<div class="tw-text-sm mb-2">
<span class="badge text-bg-primary ">{{ account.account.group }}</span>
</div>
{% endif %}
<h5 class="card-title">
{{ account.account.name }}
</h5>
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.income_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
{% if account.exchanged and account.exchanged.expense_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.expense_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="account.total_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_projected > 0 %}green{% elif account.total_projected < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged.total_projected and account.exchanged.total_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.income_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.expense_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="account.total_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_current > 0 %}green{% elif account.total_current < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.total_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div>
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'final total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace">
<c-amount.display
:amount="account.total_final"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_final > 0 %}green{% elif account.total_final < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.total_final %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_final"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
</div>
{% with p=percentages|get_dict_item:account_id %}
<div class="my-3">
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
</div>
{% endwith %}
</div>
</div>

View File

@@ -0,0 +1,179 @@
{% load tools %}
{% load i18n %}
<div class="col card shadow">
<div class="card-body">
<h5 class="card-title">
{{ currency.currency.name }}
</h5>
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="currency.income_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.income_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="currency.expense_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
{% if currency.exchanged and currency.exchanged.expense_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.expense_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="currency.total_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_projected > 0 %}green{% elif currency.total_projected < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if currency.exchanged.total_projected and currency.exchanged.total_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.total_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="currency.income_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.income_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="currency.expense_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.expense_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="currency.total_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_current > 0 %}green{% elif currency.total_current < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.total_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.total_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div>
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'final total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace">
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final > 0 %}green{% elif currency.total_final < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.total_final %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.total_final"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
</div>
{% with p=percentages|get_dict_item:currency_id %}
<div class="my-3">
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
</div>
{% endwith %}
</div>
</div>

View File

@@ -0,0 +1,243 @@
{% load i18n %}
<div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar"
_="on change from #transactions-list or htmx:afterSettle from window
if #actions-bar then
if no <input[type='checkbox']:checked/> in #transactions-list
if #actions-bar
add .slide-in-bottom-reverse then settle
then add .tw-hidden to #actions-bar
then remove .slide-in-bottom-reverse
end
else
if #actions-bar
remove .tw-hidden from #actions-bar
then trigger selected_transactions_updated
end
end
end
end">
<div class="card slide-in-bottom">
<div class="card-body p-2 d-flex justify-content-between align-items-center gap-3">
{% spaceless %}
<div class="dropdown">
<button class="btn btn-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-regular fa-square-check fa-fw"></i>
</button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
</div>
</li>
<li>
<div class="dropdown-item px-3 tw-cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square tw-text-red-400 me-3"></i>{% translate 'Unselect All' %}
</div>
</li>
</ul>
</div>
<div class="vr tw-align-middle"></div>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_undelete' %}"
hx-include=".transaction"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Restore' %}">
<i class="fa-solid fa-trash-arrow-up fa-fw"></i>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction"
hx-trigger="confirmed"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Delete' %}"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash text-danger"></i>
</button>
<div class="vr tw-align-middle"></div>
<div class="btn-group"
_="on selected_transactions_updated from #actions-bar
set realTotal to math.bignumber(0)
set flatTotal to math.bignumber(0)
set transactions to <.transaction:has(input[name='transactions']:checked)/>
set flatAmountValues to []
set realAmountValues to []
for transaction in transactions
set amt to first <.main-amount .amount/> in transaction
set amountValue to parseFloat(amt.getAttribute('data-amount'))
append amountValue to flatAmountValues
if not isNaN(amountValue)
set flatTotal to math.chain(flatTotal).add(amountValue)
if transaction match .income
append amountValue to realAmountValues
set realTotal to math.chain(realTotal).add(amountValue)
else
append -amountValue to realAmountValues
set realTotal to math.chain(realTotal).subtract(amountValue)
end
end
end
set mean to flatTotal.divide(flatAmountValues.length).done().toNumber()
set realTotal to realTotal.done().toNumber()
set flatTotal to flatTotal.done().toNumber()
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #real-total-front's innerText
put realTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-real-total's innerText
put flatTotal.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-flat-total's innerText
put Math.max.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-max's innerText
put Math.min.apply(Math, realAmountValues).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-min's innerText
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
end">
<button class="btn btn-secondary btn-sm" _="on click
set original_value to #real-total-front's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #real-total-front's innerText
wait 1s
put original_value into #real-total-front's innerText
end">
<i class="fa-solid fa-plus fa-fw me-md-2 text-primary"></i>
<span class="d-none d-md-inline-block" id="real-total-front">0</span>
</button>
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
<span class="visually-hidden">{% trans "Toggle Dropdown" %}</span>
</button>
<ul class="dropdown-menu">
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Flat Total" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-flat-total"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Real Total" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-real-total"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Mean" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-mean"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Max" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-max"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Min" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-min"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
<li>
<div class="dropdown-item-text p-0">
<div>
<div class="text-body-secondary tw-text-xs tw-font-medium px-3">
{% trans "Count" %}
</div>
<div class="dropdown-item px-3 tw-cursor-pointer"
id="calc-menu-count"
_="on click
set original_value to my innerText
writeText(my innerText) on navigator.clipboard
put '{% translate "copied!" %}' into me
wait 1s
put original_value into me
end">
0
</div>
</div>
</div>
</li>
</ul>
</div>
{% endspaceless %}
</div>
</div>
</div>

View File

@@ -1,16 +1,18 @@
{% load i18n %}
<div class="tw-sticky tw-bottom-4 tw-left-0 tw-right-0 tw-z-50 tw-hidden mx-auto tw-w-fit" id="actions-bar"
_="on change from #transactions-list or htmx:afterSettle from window
if no <input[type='checkbox']:checked/> in #transactions-list
if #actions-bar
add .slide-in-bottom-reverse then settle
then add .tw-hidden to #actions-bar
then remove .slide-in-bottom-reverse
end
else
if #actions-bar
remove .tw-hidden from #actions-bar
then trigger selected_transactions_updated
if #actions-bar then
if no <input[type='checkbox']:checked/> in #transactions-list
if #actions-bar
add .slide-in-bottom-reverse then settle
then add .tw-hidden to #actions-bar
then remove .slide-in-bottom-reverse
end
else
if #actions-bar
remove .tw-hidden from #actions-bar
then trigger selected_transactions_updated
end
end
end
end">

View File

@@ -40,8 +40,8 @@
</div>
</td>
<td class="col-3">{{ exchange_rate.date|date:"SHORT_DATETIME_FORMAT" }}</td>
<td class="col-3"><span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.from_currency.code }}</span> x <span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.to_currency.code }}</span></td>
<td class="col-3">1 {{ exchange_rate.from_currency.code }} ≅ {% currency_display amount=exchange_rate.rate prefix=exchange_rate.to_currency.prefix suffix=exchange_rate.to_currency.suffix decimal_places=exchange_rate.to_currency.decimal_places%}</td>
<td class="col-3"><span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.from_currency.name }}</span> x <span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.to_currency.name }}</span></td>
<td class="col-3">1 {{ exchange_rate.from_currency.name }} ≅ {% currency_display amount=exchange_rate.rate prefix=exchange_rate.to_currency.prefix suffix=exchange_rate.to_currency.suffix decimal_places=exchange_rate.to_currency.decimal_places%}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add exchange rate' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'automatic_exchange_rate_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit exchange rate' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'automatic_exchange_rate_edit' pk=service.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% load currency_display %}
{% load i18n %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Automatic Exchange Rates' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}"
hx-get="{% url 'automatic_exchange_rate_add' %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div>
{% endspaceless %}
</div>
<div class="card">
<div class="card-header text-body-secondary">
<button type="button" hx-get="{% url 'automatic_exchange_rate_force_fetch' %}"
class="btn btn-outline-primary btn-sm">{% trans 'Fetch all' %}</button>
</div>
<div class="card-body">
{% if services %}
<c-config.search></c-config.search>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Service' %}</th>
<th scope="col" class="col">{% translate 'Targeting' %}</th>
<th scope="col" class="col">{% translate 'Last fetch' %}</th>
</tr>
</thead>
<tbody>
{% for service in services %}
<tr class="services">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'automatic_exchange_rate_edit' pk=service.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'automatic_exchange_rate_delete' pk=service.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col-auto">{% if service.is_active %}<i class="fa-solid fa-circle text-success"></i>{% else %}
<i class="fa-solid fa-circle text-danger"></i>{% endif %}</td>
<td class="col-auto">{{ service.name }}</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.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No services configured" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,132 @@
{% load currency_display %}
{% load i18n %}
<div class="card-body show-loading" hx-get="{% url 'exchange_rates_list_pair' %}" hx-trigger="updated from:window" hx-swap="outerHTML" hx-vals='{"page": "{{ page_obj.number }}", "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'>
{% if page_obj %}
<div class="table-responsive">
<table class="table table-hover text-nowrap">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Date' %}</th>
<th scope="col" class="col">{% translate 'Pairing' %}</th>
<th scope="col" class="col">{% translate 'Rate' %}</th>
</tr>
</thead>
<tbody>
{% for exchange_rate in page_obj %}
<tr class="exchange-rate">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'exchange_rate_edit' pk=exchange_rate.id %}"
hx-target="#generic-offcanvas"
hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'exchange_rate_delete' pk=exchange_rate.id %}"
hx-trigger='confirmed'
hx-swap="innerHTML"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col-3">{{ exchange_rate.date|date:"SHORT_DATETIME_FORMAT" }}</td>
<td class="col-3"><span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.from_currency.name }}</span> x <span class="badge rounded-pill text-bg-secondary">{{ exchange_rate.to_currency.name }}</span></td>
<td class="col-3">1 {{ exchange_rate.from_currency.name }} ≅ {% currency_display amount=exchange_rate.rate prefix=exchange_rate.to_currency.prefix suffix=exchange_rate.to_currency.suffix decimal_places=exchange_rate.to_currency.decimal_places%}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No exchange rates" %}" remove-padding></c-msg.empty>
{% endif %}
{% if page_obj.has_other_pages %}
<div class="mt-auto">
<input value="{{ page_obj.number }}" name="page" type="hidden" id="page">
<nav aria-label="{% translate 'Page navigation' %}">
<ul class="pagination justify-content-center mt-5">
<li class="page-item">
<a class="page-link tw-cursor-pointer {% if not page_obj.has_previous %}disabled{% endif %}"
hx-get="{% if page_obj.has_previous %}{% url 'exchange_rates_list_pair' %}{% endif %}"
hx-vals='{"page": 1, "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'
hx-include="#filter, #order"
hx-target="#exchange-rates-table"
aria-label="Primeira página"
hx-swap="show:top">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% for page_number in page_obj.paginator.page_range %}
{% comment %}
This conditional allows us to display up to 3 pages before and after the current page
If you decide to remove this conditional, all the pages will be displayed
You can change the 3 to any number you want e.g
To display only 5 pagination items, change the 3 to 2 (2 before and 2 after the current page)
{% endcomment %}
{% if page_number <= page_obj.number|add:3 and page_number >= page_obj.number|add:-3 %}
{% if page_obj.number == page_number %}
<li class="page-item active">
<a class="page-link tw-cursor-pointer">
{{ page_number }}
</a>
</li>
{% else %}
<li class="page-item">
<a class="page-link tw-cursor-pointer"
hx-get="{% url 'exchange_rates_list_pair' %}"
hx-vals='{"page": {{ page_number }}, "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'
hx-target="#exchange-rates-table"
hx-swap="show:top">
{{ page_number }}
</a>
</li>
{% endif %}
{% endif %}
{% endfor %}
{% if page_obj.number|add:3 < page_obj.paginator.num_pages %}
<li class="page-item">
<a class="page-link disabled"
aria-label="...">
<span aria-hidden="true">...</span>
</a>
</li>
<li class="page-item">
<a class="page-link tw-cursor-pointer"
hx-get="{% url 'exchange_rates_list_pair' %}" hx-target="#exchange-rates-table"
hx-vals='{"page": {{ page_obj.paginator.num_pages }}, "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'
hx-include="#filter, #order"
hx-swap="show:top"
aria-label="Última página">
<span aria-hidden="true">{{ page_obj.paginator.num_pages }}</span>
</a>
</li>
{% endif %}
<li class="page-item">
<a class="page-link {% if not page_obj.has_next %}disabled{% endif %} tw-cursor-pointer"
hx-get="{% if page_obj.has_next %}{% url 'exchange_rates_list_pair' %}{% endif %}"
hx-vals='{"page": {{ page_obj.paginator.num_pages }}, "from": "{{ from_currency|default_if_none:"" }}", "to": "{{ to_currency|default_if_none:"" }}"}'
hx-include="#filter, #order"
hx-swap="show:top"
hx-target="#exchange-rates-table"
aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Automatic Exchange Rates' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'automatic_exchange_rates_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -1,3 +1,4 @@
{% load settings %}
{% load static %}
{% load i18n %}
{% load active_link %}
@@ -56,7 +57,13 @@
<li><a class="dropdown-item {% active_link views='transactions_all_index' %}"
href="{% url 'transactions_all_index' %}">{% translate 'All' %}</a></li>
<li>
<hr class="dropdown-divider">
{% settings "ENABLE_SOFT_DELETE" as enable_soft_delete %}
{% if enable_soft_delete %}
<li><a class="dropdown-item {% active_link views='transactions_trash_index' %}"
href="{% url 'transactions_trash_index' %}">{% translate 'Trash Can' %}</a></li>
<li>
{% endif %}
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
@@ -122,6 +129,8 @@
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
<li>
<hr class="dropdown-divider">
</li>

View File

@@ -4,11 +4,13 @@
{% javascript_pack 'sweetalert2' attrs="defer" %}
{% javascript_pack 'select' attrs="defer" %}
{% javascript_pack 'datepicker' %}
{% javascript_pack 'autosize' attrs="defer" %}
{% include 'includes/scripts/hyperscript/init_tom_select.html' %}
{% include 'includes/scripts/hyperscript/init_date_picker.html' %}
{% include 'includes/scripts/hyperscript/hide_amount.html' %}
{% include 'includes/scripts/hyperscript/tooltip.html' %}
{% include 'includes/scripts/hyperscript/autosize.html' %}
{% include 'includes/scripts/hyperscript/htmx_error_handler.html' %}
{% include 'includes/scripts/hyperscript/sounds.html' %}
{% include 'includes/scripts/hyperscript/swal.html' %}

View File

@@ -0,0 +1,7 @@
<script type="text/hyperscript">
on htmx:afterSettle
for elem in <.textarea/>
autosize(elem)
end
end
</script>

View File

@@ -1,5 +1,5 @@
<div id="toasts">
<div class="toast-container position-fixed bottom-0 end-0 p-3" hx-trigger="load, updated from:window" hx-get="{% url 'toasts' %}" hx-swap="beforeend">
<div class="toast-container position-fixed bottom-0 end-0 p-3" hx-trigger="load, updated from:window, toasts from:window" hx-get="{% url 'toasts' %}" hx-swap="beforeend">
</div>
</div>

View File

@@ -0,0 +1,17 @@
{% load tools %}
{% load i18n %}
{% load currency_display %}
<div class="row row-cols-1 g-4 mt-1 mb-3">
{% for account_id, account in account_data.items %}
<div class="col">
<c-ui.account_card :account="account" :account_id="account_id"
:percentages="account_percentages"></c-ui.account_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,16 @@
{% load tools %}
{% load i18n %}
{% load currency_display %}
<div class="row row-cols-1 g-4 mt-1 mb-3">
{% for currency_id, currency in currency_data.items %}
<div class="col">
<c-ui.currency_card :currency="currency" :currency_id="currency_id"
:percentages="currency_percentages"></c-ui.currency_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
</div>
{% endfor %}
</div>

View File

@@ -1,6 +1,6 @@
{% load i18n %}
{% load currency_display %}
<div class="row row-cols-1 g-4 mb-3">
<div class="row row-cols-1 g-4 mt-1 mb-3">
{# Daily Spending#}
<div class="col">
<c-ui.info-card color="yellow" icon="fa-solid fa-calendar-day" title="{% trans 'Daily Spending Allowance' %}" help_text={% trans "This is the final total divided by the remaining days in the month" %}>
@@ -252,6 +252,7 @@
</div>
</c-ui.info-card>
</div>
{% if percentages %}
<div class="col">
<c-ui.info-card color="yellow" icon="fa-solid fa-percent" title="{% trans 'Distribution' %}">
{% for p in percentages.values %}
@@ -260,4 +261,5 @@
{% endfor %}
</c-ui.info-card>
</div>
{% endif %}
</div>

View File

@@ -13,89 +13,172 @@
{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
{# Date picker#}
<div class="col-12 col-xl-4 flex-row align-items-center d-flex">
<div class="tw-text-base h-100 align-items-center d-flex">
<a role="button"
class="pe-4 py-2"
hx-boost="true"
hx-trigger="click, previous_month from:window"
href="{% url 'monthly_overview' month=previous_month year=previous_year %}"><i
class="fa-solid fa-chevron-left"></i></a>
</div>
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center"
hx-get="{% url 'month_year_picker' %}"
hx-target="#generic-offcanvas-left"
hx-trigger="click, date_picker from:window"
hx-vals='{"month": {{ month }}, "year": {{ year }}, "for": "monthly_overview", "field": "reference_date"}' role="button">
{{ month|month_name }} {{ year }}
</div>
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
<a role="button"
class="ps-3 py-2"
hx-boost="true"
hx-trigger="click, next_month from:window"
href="{% url 'monthly_overview' month=next_month year=next_year %}">
<i class="fa-solid fa-chevron-right"></i>
</a>
</div>
</div>
{# Action buttons#}
<div class="col-12 col-xl-8">
<c-ui.quick-transactions-buttons
:year="year"
:month="month"
></c-ui.quick-transactions-buttons>
</div>
</div>
{# Monthly summary#}
<div class="row gx-xl-4 gy-3">
<div class="col-12 col-xl-4 order-0 order-xl-2">
<div id="summary" hx-get="{% url 'monthly_summary' month=month year=year %}" class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
</div>
</div>
<div class="col-12 col-xl-8 order-2 order-xl-1">
<div class="row mb-1">
<div class="col-sm-6 col-12">
{# Filter transactions button #}
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false" aria-controls="collapse-filter">
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
</button>
<div class="container px-md-3 py-3 column-gap-5">
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
{# Date picker#}
<div class="col-12 col-xl-4 flex-row align-items-center d-flex">
<div class="tw-text-base h-100 align-items-center d-flex">
<a role="button"
class="pe-4 py-2"
hx-boost="true"
hx-trigger="click, previous_month from:window"
href="{% url 'monthly_overview' month=previous_month year=previous_year %}"><i
class="fa-solid fa-chevron-left"></i></a>
</div>
{# Ordering button#}
<div class="col-sm-6 col-12 tw-content-center my-3 my-sm-0">
<div class="text-sm-end" _="on change trigger updated on window">
<label for="order">{% translate "Order by" %}</label>
<select class="tw-border-0 focus-visible:tw-outline-0 w-full pe-2 tw-leading-normal text-bg-tertiary tw-font-medium rounded" name="order" id="order">
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</select>
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center"
hx-get="{% url 'month_year_picker' %}"
hx-target="#generic-offcanvas-left"
hx-trigger="click, date_picker from:window"
hx-vals='{"month": {{ month }}, "year": {{ year }}, "for": "monthly_overview", "field": "reference_date"}'
role="button">
{{ month|month_name }} {{ year }}
</div>
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
<a role="button"
class="ps-3 py-2"
hx-boost="true"
hx-trigger="click, next_month from:window"
href="{% url 'monthly_overview' month=next_month year=next_year %}">
<i class="fa-solid fa-chevron-right"></i>
</a>
</div>
</div>
{# Action buttons#}
<div class="col-12 col-xl-8">
<c-ui.quick-transactions-buttons
:year="year"
:month="month"
></c-ui.quick-transactions-buttons>
</div>
</div>
{# Monthly summary#}
<div class="row gx-xl-4 gy-3">
<div class="col-12 col-xl-4 order-0 order-xl-2">
<ul class="nav nav-tabs" id="monthly-summary" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if summary_tab == 'summary' %}active{% endif %}"
id="summary-tab"
data-bs-toggle="tab"
data-bs-target="#summary-tab-pane"
type="button"
role="tab"
aria-controls="summary-tab-pane"
_="on click fetch {% url 'monthly_summary_select' selected='summary' %}"
aria-selected="{% if summary_tab == 'summary' or not summary_tab %}true{% else %}false{% endif %}">
{% trans 'Summary' %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if summary_tab == 'currency' %}active{% endif %}"
id="currency-tab"
data-bs-toggle="tab"
data-bs-target="#currency-tab-pane"
type="button"
role="tab"
aria-controls="currency-tab-pane"
_="on click fetch {% url 'monthly_summary_select' selected='currency' %}"
aria-selected="{% if summary_tab == 'currency' %}true{% else %}false{% endif %}">
{% trans 'Currencies' %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if summary_tab == 'account' %}active{% endif %}"
id="account-tab"
data-bs-toggle="tab"
data-bs-target="#account-tab-pane"
type="button"
role="tab"
aria-controls="account-tab-pane"
_="on click fetch {% url 'monthly_summary_select' selected='account' %}"
aria-selected="{% if summary_tab == 'account' %}true{% else %}false{% endif %}">
{% trans 'Accounts' %}
</button>
</li>
</ul>
<div class="tab-content" id="monthly-summary-content">
<div class="tab-pane fade {% if summary_tab == 'summary' %}show active{% endif %}"
id="summary-tab-pane"
role="tabpanel"
aria-labelledby="summary-tab"
tabindex="0">
<div id="summary"
hx-get="{% url 'monthly_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
</div>
</div>
<div class="tab-pane fade {% if summary_tab == 'currency' %}show active{% endif %}"
id="currency-tab-pane"
role="tabpanel"
aria-labelledby="currency-tab"
tabindex="0">
<div id="currency-summary"
hx-get="{% url 'monthly_currency_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
</div>
</div>
<div class="tab-pane fade {% if summary_tab == 'account' %}show active{% endif %}"
id="account-tab-pane"
role="tabpanel"
aria-labelledby="account-tab"
tabindex="0">
<div id="account-summary"
hx-get="{% url 'monthly_account_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window">
</div>
</div>
</div>
</div>
{# Filter transactions form#}
<div class="collapse" id="collapse-filter">
<div class="card card-body">
<form _="on change or submit or search trigger updated on window end
<div class="col-12 col-xl-8 order-2 order-xl-1">
<div class="row mb-1">
<div class="col-sm-6 col-12">
{# Filter transactions button #}
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false"
aria-controls="collapse-filter">
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
</button>
</div>
{# Ordering button#}
<div class="col-sm-6 col-12 tw-content-center my-3 my-sm-0">
<div class="text-sm-end" _="on change trigger updated on window">
<label for="order">{% translate "Order by" %}</label>
<select
class="tw-border-0 focus-visible:tw-outline-0 w-full pe-2 tw-leading-normal text-bg-tertiary tw-font-medium rounded"
name="order" id="order">
<option value="default"
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older"
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<option value="newer"
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</select>
</div>
</div>
</div>
{# Filter transactions form#}
<div class="collapse" id="collapse-filter">
<div class="card card-body">
<form _="on change or submit or search trigger updated on window end
install init_tom_select
install init_datepicker"
id="filter">
{% crispy filter.form %}
</form>
<button class="btn btn-outline-danger btn-sm" _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
id="filter">
{% crispy filter.form %}
</form>
<button class="btn btn-outline-danger btn-sm"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
{# Transactions list#}
<div id="transactions"
class="show-loading"
hx-get="{% url 'monthly_transactions_list' month=month year=year %}"
hx-trigger="load, updated from:window" hx-include="#filter, #order">
</div>
</div>
{# Transactions list#}
<div id="transactions"
class="show-loading"
hx-get="{% url 'monthly_transactions_list' month=month year=year %}"
hx-trigger="load, updated from:window" hx-include="#filter, #order">
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -46,6 +46,22 @@
color="grey"></c-amount.display>
</div>
{% endif %}
{% if currency.consolidated %}
<div class="d-flex align-items-baseline w-100">
<div class="account-name text-start font-monospace tw-text-gray-300">
<span class="hierarchy-line-icon"></span>{% trans 'Consolidated' %}</div>
<div class="dotted-line flex-grow-1"></div>
<div class="">
<c-amount.display
:amount="currency.consolidated.total_final"
:prefix="currency.consolidated.currency.prefix"
:suffix="currency.consolidated.currency.suffix"
:decimal_places="currency.consolidated.currency.decimal_places"
color="{% if currency.total_final > 0 %}green{% elif currency.total_final < 0 %}red{% endif %}"
text-end></c-amount.display>
</div>
</div>
{% endif %}
{% endfor %}
</c-ui.info-card>
</div>

View File

@@ -0,0 +1,17 @@
{% load tools %}
{% load i18n %}
{% load currency_display %}
<div class="row row-cols-1 g-4 mt-1 mb-3">
{% for account_id, account in account_data.items %}
<div class="col">
<c-ui.account_card :account="account" :account_id="account_id"
:percentages="account_percentages"></c-ui.account_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,16 @@
{% load tools %}
{% load i18n %}
{% load currency_display %}
<div class="row row-cols-1 g-4 mt-1 mb-3">
{% for currency_id, currency in currency_data.items %}
<div class="col">
<c-ui.currency_card :currency="currency" :currency_id="currency_id"
:percentages="currency_percentages"></c-ui.currency_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
</div>
{% endfor %}
</div>

View File

@@ -1,431 +1,47 @@
{% load tools %}
{% load i18n %}
{% load currency_display %}
<ul class="nav nav-tabs" id="myTab" role="tablist">
<ul class="nav nav-tabs" id="all-trasactions-tab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="currency-tab" data-bs-toggle="tab" data-bs-target="#currency-tab-pane" type="button" role="tab" aria-controls="currency-tab-pane" aria-selected="true">{% trans 'Currencies' %}</button>
<button class="nav-link active" id="currency-tab" data-bs-toggle="tab" data-bs-target="#currency-tab-pane"
type="button" role="tab" aria-controls="currency-tab-pane"
aria-selected="true">{% trans 'Currencies' %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="account-tab" data-bs-toggle="tab" data-bs-target="#account-tab-pane" type="button" role="tab" aria-controls="account-tab-pane" aria-selected="false">{% trans 'Accounts' %}</button>
<button class="nav-link" id="account-tab" data-bs-toggle="tab" data-bs-target="#account-tab-pane" type="button"
role="tab" aria-controls="account-tab-pane" aria-selected="false">{% trans 'Accounts' %}</button>
</li>
</ul>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="currency-tab-pane" role="tabpanel" aria-labelledby="currency-tab" tabindex="0">
<div class="tab-content" id="all-transactions-content">
<div class="tab-pane fade show active" id="currency-tab-pane" role="tabpanel" aria-labelledby="currency-tab"
tabindex="0">
<div class="row row-cols-1 g-4 mt-2">
{# Income#}
<div class="col">
<c-ui.info-card color="green" icon="fa-solid fa-arrow-right-to-bracket" title="{% trans 'Income' %}">
<div class="d-flex justify-content-between mt-3">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current' %}</div>
</div>
<div class="text-end font-monospace">
{% for currency in income_current.values %}
<div>
<c-amount.display
:amount="currency.income_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.income_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
<hr class="my-1">
<div class="d-flex justify-content-between">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected' %}</div>
</div>
<div class="text-end font-monospace">
{% for currency in income_projected.values %}
<div>
<c-amount.display
:amount="currency.income_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.income_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
</c-ui.info-card>
</div>
{# Expenses#}
<div class="col">
<c-ui.info-card color="red" icon="fa-solid fa-arrow-right-from-bracket" title="{% trans 'Expenses' %}">
<div class="d-flex justify-content-between mt-3">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current' %}</div>
</div>
<div class="text-end font-monospace">
{% for currency in expense_current.values %}
<div>
<c-amount.display
:amount="currency.expense_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.expense_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
<hr class="my-1">
<div class="d-flex justify-content-between">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected' %}</div>
</div>
<div class="text-end font-monospace">
{% for currency in expense_projected.values %}
<div>
<c-amount.display
:amount="currency.expense_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.expense_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
</c-ui.info-card>
</div>
{# Total#}
<div class="col">
<c-ui.info-card color="blue" icon="fa-solid fa-scale-balanced" title="{% trans 'Total' %}">
<div class="d-flex justify-content-between mt-3">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current' %}</div>
</div>
<div class="text-end font-monospace">
{% for currency in total_current.values %}
<div>
<c-amount.display
:amount="currency.total_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_current > 0 %}green{% elif currency.total_current < 0 %}red{% endif %}"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.total_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
<div class="d-flex justify-content-between mt-3">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected' %}</div>
</div>
<div class="text-end font-monospace">
{% for currency in total_projected.values %}
<div>
<c-amount.display
:amount="currency.total_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_projected > 0 %}green{% elif currency.total_projected < 0 %}red{% endif %}"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.total_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
<hr class="my-1">
<div class="d-flex justify-content-end">
<div class="text-end font-monospace">
{% for currency in total_final.values %}
<div>
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final > 0 %}green{% elif currency.total_final < 0 %}red{% endif %}"></c-amount.display>
</div>
{% if currency.exchanged %}
<div>
<c-amount.display
:amount="currency.exchanged.total_final"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"
color="gray"></c-amount.display>
</div>
{% endif %}
{% empty %}
<div>-</div>
{% endfor %}
</div>
</div>
</c-ui.info-card>
</div>
<div class="col">
<c-ui.info-card color="yellow" icon="fa-solid fa-percent" title="{% trans 'Distribution' %}">
{% for p in currency_percentages.values %}
<p class="tw-text-gray-400 mb-2 {% if not forloop.first %}mt-3{% endif %}">{{ p.currency.name }} ({{ p.currency.code }})</p>
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
{% endfor %}
</c-ui.info-card>
</div>
</div>
</div>
<div class="tab-pane fade" id="account-tab-pane" role="tabpanel" aria-labelledby="account-tab" tabindex="0">
<div class="row row-cols-1 g-4 mt-2">
<div class="col">
{% for account_id, account in account_data.items %}
{% if not single %}
<div class="tw-text-xl {% if not forloop.first %}mt-4 mb-3{% endif %}">
{% if account.account.group %}
<span class="badge text-bg-primary me-2">{{ account.account.group }}</span>{% endif %}{{ account.account.name }}
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
{% for currency_id, currency in currency_data.items %}
<div class="col">
<c-ui.currency_card :currency="currency" :currency_id="currency_id"
:percentages="currency_percentages"></c-ui.currency_card>
</div>
{% if account.exchanged and account.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.currency.income_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
{% if account.exchanged and account.exchanged.expense_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.expense_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="account.total_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_projected > 0 %}green{% elif account.total_projected < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged.total_projected and account.exchanged.total_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.income_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.expense_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="account.total_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_current > 0 %}green{% elif account.total_current < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.total_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div>
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'final total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace">
<c-amount.display
:amount="account.total_final"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_final > 0 %}green{% elif account.total_final < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.total_final %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_final"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
</div>
{% with p=account_percentages|get_dict_item:account_id %}
<div class="my-3">
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
</div>
{% endwith %}
<hr>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
{% endfor %}
</div>
</div>
</div>
<div class="tab-pane fade" id="account-tab-pane" role="tabpanel" aria-labelledby="account-tab" tabindex="0">
<div class="row row-cols-1 g-4 mt-2">
{% for account_id, account in account_data.items %}
<div class="col">
<c-ui.account_card :account="account" :account_id="account_id"
:percentages="account_percentages"></c-ui.account_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
{% endfor %}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,11 @@
{% load i18n %}
<div class="trash-list-container" id="transactions-list">
{% for transaction in transactions %}
<c-transaction.item :transaction="transaction"></c-transaction.item>
{% empty %}
<c-msg.empty
title="{% translate "No deleted transactions to show" %}"></c-msg.empty>
{% endfor %}
{# Floating bar #}
<c-ui.deleted-transactions-action-bar></c-ui.deleted-transactions-action-bar>
</div>

View File

@@ -45,10 +45,59 @@
</div>
</div>
<div class="col-12 col-xl-3 order-1 order-xl-2">
<div id="transactions"
class="show-loading"
hx-get="{% url 'transactions_all_summary' %}"
hx-trigger="load, updated from:window, change from:#filter, submit from:#filter, search from:#filter" hx-include="#filter">
<ul class="nav nav-tabs" id="all-transactions-summary" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if summary_tab == 'currency' %}active{% endif %}"
id="currency-tab"
data-bs-toggle="tab"
data-bs-target="#currency-tab-pane"
type="button"
role="tab"
aria-controls="currency-tab-pane"
_="on click fetch {% url 'transaction_all_summary_select' selected='currency' %}"
aria-selected="{% if summary_tab == 'currency' %}true{% else %}false{% endif %}">
{% trans 'Currencies' %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if summary_tab == 'account' %}active{% endif %}"
id="account-tab"
data-bs-toggle="tab"
data-bs-target="#account-tab-pane"
type="button"
role="tab"
aria-controls="account-tab-pane"
_="on click fetch {% url 'transaction_all_summary_select' selected='account' %}"
aria-selected="{% if summary_tab == 'account' %}true{% else %}false{% endif %}">
{% trans 'Accounts' %}
</button>
</li>
</ul>
<div class="tab-content" id="all-transactions-content">
<div class="tab-pane fade {% if summary_tab == 'currency' %}show active{% endif %}"
id="currency-tab-pane"
role="tabpanel"
aria-labelledby="currency-tab"
tabindex="0">
<div id="currency-summary"
hx-get="{% url 'transaction_all_currency_summary' %}"
class="show-loading"
hx-trigger="load, selective_update from:window, updated from:window, change from:#filter, submit from:#filter, search from:#filter"
hx-include="#filter">
</div>
</div>
<div class="tab-pane fade {% if summary_tab == 'account' %}show active{% endif %}"
id="account-tab-pane"
role="tabpanel"
aria-labelledby="account-tab"
tabindex="0">
<div id="account-summary"
hx-get="{% url 'transaction_all_account_summary' %}"
class="show-loading"
hx-trigger="load, selective_update from:window, updated from:window, change from:#filter, submit from:#filter, search from:#filter"
hx-include="#filter">
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Deleted transactions' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
<div>{% translate 'Deleted transactions' %}</div>
</div>
<div hx-get="{% url 'transactions_trash_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
</div>
{% endblock %}

View File

@@ -1,188 +1,15 @@
{% load tools %}
{% load i18n %}
<div class="row row-cols-1 g-4 mb-3">
{% for account_id, account in totals.items %}
<div class="col">
{% for account_id, account in totals.items %}
{% if not single %}
<div class="tw-text-xl {% if not forloop.first %}mt-4 mb-3{% endif %}">
{% if account.account.group %}
<span class="badge text-bg-primary me-2">{{ account.account.group }}</span>{% endif %}{{ account.account.name }}
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.currency.income_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
{% if account.exchanged and account.exchanged.expense_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.expense_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="account.total_projected"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_projected > 0 %}green{% elif account.total_projected < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged.total_projected and account.exchanged.total_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_projected"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.income_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.expense_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="account.total_current"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_current > 0 %}green{% elif account.total_current < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.total_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_current"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div>
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'final total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace">
<c-amount.display
:amount="account.total_final"
:prefix="account.currency.prefix"
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"
color="{% if account.total_final > 0 %}green{% elif account.total_final < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if account.exchanged and account.exchanged.total_final %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="account.exchanged.total_final"
:prefix="account.exchanged.currency.prefix"
:suffix="account.exchanged.currency.suffix"
:decimal_places="account.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
</div>
{% with p=percentages|get_dict_item:account_id %}
<div class="my-3">
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
</div>
{% endwith %}
<hr>
{% empty %}
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
{% endfor %}
<c-ui.account_card :account="account" :account_id="account_id"
:percentages="percentages"></c-ui.account_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
</div>
{% endfor %}
</div>

View File

@@ -1,187 +1,15 @@
{% load tools %}
{% load month_name %}
{% load i18n %}
<div class="row row-cols-1 g-4 mb-3">
{% for currency_id, currency in totals.items %}
{% for currency_id, currency in totals.items %}
<div class="col">
{% if not single %}
<div class="tw-text-xl {% if not forloop.first %}mt-4 mb-3{% endif %}">
{{ currency.currency.name }} ({{ currency.currency.code }})
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="currency.income_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.currency.income_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="currency.expense_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
</div>
{% if currency.exchanged and currency.exchanged.expense_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.expense_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'projected total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="currency.total_projected"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_projected > 0 %}green{% elif currency.total_projected < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if currency.exchanged.total_projected and currency.exchanged.total_projected %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.total_projected"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="currency.income_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.income_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="currency.expense_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.expense_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<c-amount.display
:amount="currency.total_current"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_current > 0 %}green{% elif currency.total_current < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.total_current %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.total_current"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
<div>
<hr class="my-3">
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="text-end font-monospace">
<div class="tw-text-gray-400">{% translate 'final total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div class="text-end font-monospace">
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final > 0 %}green{% elif currency.total_final < 0 %}red{% endif %}"></c-amount.display>
</div>
</div>
{% if currency.exchanged and currency.exchanged.total_final %}
<div class="text-end font-monospace tw-text-gray-500">
<c-amount.display
:amount="currency.exchanged.total_final"
:prefix="currency.exchanged.currency.prefix"
:suffix="currency.exchanged.currency.suffix"
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
</div>
{% with p=percentages|get_dict_item:currency_id %}
<div class="my-3">
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
</div>
{% endwith %}
{% empty %}
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
<c-ui.currency_card :currency="currency" :currency_id="currency_id"
:percentages="percentages"></c-ui.currency_card>
</div>
{% empty %}
<div class="col">
<c-msg.empty
title="{% translate "No information to display" %}"></c-msg.empty>
</div>
{% endfor %}
</div>

View File

@@ -4,5 +4,11 @@ set -o errexit
set -o pipefail
set -o nounset
rm -f /tmp/migrations_complete
python manage.py migrate
# Create flag file to signal migrations are complete
touch /tmp/migrations_complete
exec python manage.py runserver 0.0.0.0:8000

View File

@@ -4,4 +4,12 @@ set -o errexit
set -o nounset
exec watchfiles --filter python "python manage.py procrastinate worker"
# Wait for migrations to complete
until [ -f /tmp/migrations_complete ]; do
echo "Procastinate is waiting for web app to start..."
sleep 2
done
rm -f /tmp/migrations_complete
exec python manage.py procrastinate worker

View File

@@ -20,7 +20,7 @@ directory=/usr/src/app
command=/bin/bash /start
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile=/dev/fd/1
stderr_logfile_maxbytes=0
autorestart=true
startretries=5
@@ -33,7 +33,7 @@ numprocs=%(ENV_TASK_WORKERS)s
numprocs_start=1
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile=/dev/fd/1
stderr_logfile_maxbytes=0
autorestart=true
startretries=5

View File

@@ -4,8 +4,13 @@ set -o errexit
set -o pipefail
set -o nounset
# Remove flag file if it exists from previous run
rm -f /tmp/migrations_complete
python manage.py collectstatic --noinput
python manage.py migrate
# Create flag file to signal migrations are complete
touch /tmp/migrations_complete
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:8000 --timeout 600

View File

@@ -4,4 +4,12 @@ set -o errexit
set -o nounset
# Wait for migrations to complete
until [ -f /tmp/migrations_complete ]; do
echo "Procastinate is waiting for web app to start..."
sleep 2
done
rm -f /tmp/migrations_complete
exec python manage.py procrastinate worker

View File

@@ -17,7 +17,7 @@ directory=/usr/src/app
command=/bin/bash /start
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile=/dev/fd/1
stderr_logfile_maxbytes=0
autorestart=true
startretries=5
@@ -31,7 +31,7 @@ numprocs=%(ENV_TASK_WORKERS)s
numprocs_start=1
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile=/dev/fd/1
stderr_logfile_maxbytes=0
autorestart=true
startretries=5

View File

@@ -1,54 +1,3 @@
import autosize from "autosize/dist/autosize";
let autosize_textareas = document.querySelectorAll('textarea[autosize]');
autosize(autosize_textareas);
document.addEventListener('shown.bs.collapse', function () {
autosize.update(autosize_textareas);
});
// UPDATE AUTOSIZE TEXT AREAS FOR FORMS INSIDE HTMX MODALS
document.addEventListener('updated.bs.modal', function () {
let new_autosize_textareas = document.querySelectorAll('textarea[autosize]');
autosize(new_autosize_textareas);
});
let charcount_textareas = document.querySelectorAll('textarea[countchars], input[countchars]');
charcount_textareas.forEach(formElement => {
countTextArea(formElement);
formElement.addEventListener('input', () => countTextArea(formElement));
});
function countTextArea(formElement) {
let name = formElement.name;
let max_chars = null;
if (formElement.dataset.maxChars) {
max_chars = formElement.dataset.maxChars;
} else if (formElement.hasAttribute("maxlength")) {
max_chars = formElement.getAttribute("maxlength");
}
let cur_chars = formElement.value.length;
let wrapper = document.querySelector(`#charcount-${name}`);
let char_counter = document.querySelector(`#char-counter-${name}`);
let max_counter = document.querySelector(`#max-counter-${name}`);
char_counter.textContent = cur_chars;
if (max_counter) {
max_counter.textContent = max_chars;
wrapper.classList.remove("text-bg-warning", "text-bg-normal", "text-bg-success", "text-bg-danger");
if (cur_chars === 0) {
wrapper.classList.add("text-bg-secondary");
} else if (cur_chars > max_chars - 1) {
wrapper.classList.add("text-bg-danger");
} else if (cur_chars < max_chars && cur_chars > max_chars * (90 / 100)) {
wrapper.classList.add("text-bg-warning");
} else if (cur_chars < max_chars - ((max_chars * (10 / 100)) - 1)) {
wrapper.classList.add("text-bg-success");
}
}
}
window.autosize = autosize;