Compare commits

...

71 Commits
0.8.4 ... 0.9.4

Author SHA1 Message Date
Herculino Trotta
851b34f07a Merge pull request #156 from eitchtee/dev
fix(transactions): paying transaction doesn't trigger update rules
2025-02-09 23:38:58 -03:00
Herculino Trotta
546ed5c6af fix(transactions): bulk (un)paying transactions doesn't trigger update rules 2025-02-09 23:38:22 -03:00
Herculino Trotta
04ae7337f5 fix(transactions): paying transaction doesn't trigger update rules 2025-02-09 23:33:57 -03:00
Herculino Trotta
63069f0ec9 Merge pull request #155 from eitchtee/dev
refactor: don't display currency code
2025-02-09 19:50:09 -03:00
Herculino Trotta
32b522dad2 refactor: don't display currency code 2025-02-09 19:49:47 -03:00
Herculino Trotta
0c20a079e3 Merge pull request #154 from eitchtee/dev
locale: update locales
2025-02-09 17:31:03 -03:00
Herculino Trotta
7c9697f683 locale: update locales 2025-02-09 17:30:39 -03:00
Herculino Trotta
15d04230ae Merge pull request #153
feat(monthly): add quick-search field
2025-02-09 17:14:44 -03:00
Herculino Trotta
ecc09ca6a6 feat(monthly): add quick-search field 2025-02-09 17:14:25 -03:00
Herculino Trotta
cd753c5dd5 Merge pull request #152 from luzpaz/readme-typos
fix: typos in README
2025-02-09 10:55:54 -03:00
luzpaz
a3b9952f80 fix: typos in README
Found via `codespell -q 3 -S "*.po" -L bu,nome,vew`
2025-02-09 09:47:03 +00:00
Herculino Trotta
e93969c035 Merge pull request #151
feat(import:v1): add XLS and XLSX support
2025-02-09 00:51:46 -03:00
Herculino Trotta
6ec5b5df1e feat(import:v1): add XLS and XLSX support
Closes #47
2025-02-09 00:51:26 -03:00
Herculino Trotta
93e7adeea8 Merge pull request #150 from eitchtee/dev
feat(import): add Cajamar preset
2025-02-09 00:50:38 -03:00
Herculino Trotta
37b5a43c1f feat(import): add Cajamar preset
Thanks to Pablo Hinojosa for sharing his file
2025-02-09 00:50:11 -03:00
Herculino Trotta
87a07c25d1 Merge pull request #149
feat(import:v1): add "add" and "subtract" transformations
2025-02-08 18:30:25 -03:00
Herculino Trotta
9e27fef5e5 feat(import:v1): add "add" and "subtract" transformations 2025-02-08 18:30:06 -03:00
Herculino Trotta
2cbba53e06 Merge pull request #148
feat(import:v1): allow to source previously mapped data by prefixing it with "__" on transformations
2025-02-08 16:38:57 -03:00
Herculino Trotta
d9e8be7efb feat(import:v1): allow to source previously mapped data by prefixing it with "__" on transformations 2025-02-08 16:38:36 -03:00
Herculino Trotta
7dc9ef9950 Merge pull request #147 from eitchtee/dev
refactor(import:v1): remove forced "required" from some fields
2025-02-08 16:36:48 -03:00
Herculino Trotta
00e83cf6a2 refactor(import:v1): remove forced "required" from some fields 2025-02-08 16:35:46 -03:00
Herculino Trotta
039242b48a Merge pull request #146 from eitchtee/dev
fix(dev): django-browser-reload not working
2025-02-08 16:01:06 -03:00
Herculino Trotta
94e2bdf93d fix(dev): django-browser-reload not working 2025-02-08 16:00:45 -03:00
Herculino Trotta
79b387ce60 Merge pull request #145 from eitchtee/dev
feat(import:v1): allow to source previously mapped data by prefixing it with "__"
2025-02-08 15:59:56 -03:00
Herculino Trotta
43eb87d3ba feat(import:v1): allow to source previously mapped data by prefixing it with "__" 2025-02-08 15:59:27 -03:00
Herculino Trotta
0110220b72 Merge pull request #144 from eitchtee/dev
feat: account and currency cards will no longer display unneeded zeros, only for totals
2025-02-08 11:43:24 -03:00
Herculino Trotta
f5c86f3d97 feat: account and currency cards will no longer display unneeded zeros, only for totals 2025-02-08 11:42:46 -03:00
Herculino Trotta
7b7f58d34d Merge pull request #143 from eitchtee/dev
fix(logging): procrastinate job logs not showing up
2025-02-08 04:19:03 -03:00
Herculino Trotta
86112931d9 fix(logging): procrastinate job logs not showing up 2025-02-08 04:18:33 -03:00
Herculino Trotta
e6e0e4caea Merge pull request #142
feat(rules): add Update or Create Transaction action
2025-02-08 04:18:00 -03:00
Herculino Trotta
942154480e feat(rules): add Update or Create Transaction action 2025-02-08 04:17:28 -03:00
Herculino Trotta
467131d9f1 feat(rules): add Update or Create Transaction action 2025-02-08 04:16:28 -03:00
Herculino Trotta
fee1db8660 Merge pull request #141
fix(automatic-exchange-rates): skipping hours due to minutes
2025-02-07 14:34:58 -03:00
Herculino Trotta
4f7fc1c9c8 fix(automatic-exchange-rates): skipping hours due to minutes 2025-02-07 14:34:38 -03:00
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
94 changed files with 7175 additions and 2406 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 [Environment Variables](#environment-variables) for an explanation of all available variables
## Running locally
If you want to run WYGIWYH locally, on your env file:
@@ -105,7 +108,9 @@ 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.
## Enviroment Variables
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.
## Environment Variables
| variable | type | default | explanation |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
@@ -117,7 +122,7 @@ WYGIWYH is available on the Unraid Store. You'll need to provision your own post
| SQL_DATABASE | string | None *required | The name of your postgres database |
| SQL_USER | string | user | The username used to connect to your postgres database |
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
| SQL_HOST | string | localhost | The adress used to connect to your postgres database |
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |

View File

@@ -75,6 +75,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
"django_browser_reload.middleware.BrowserReloadMiddleware",
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware",
@@ -87,7 +88,6 @@ MIDDLEWARE = [
"apps.common.middleware.localization.LocalizationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"hijack.middleware.HijackUserMiddleware",
]
@@ -166,7 +166,7 @@ LANGUAGES = (
("pt-br", "Português (Brasil)"),
)
TIME_ZONE = "UTC"
TIME_ZONE = os.getenv("TZ", "UTC")
USE_I18N = True
@@ -277,29 +277,32 @@ if "procrastinate" in sys.argv:
"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",
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
"formatter": "standard",
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "INFO",
},
},
"loggers": {
"procrastinate": {
"handlers": ["procrastinate"],
"level": "INFO",
"propagate": False,
},
"root": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
@@ -308,24 +311,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,223 @@
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.info(
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
)
should_fetch = current_hour in allowed_hours
logger.info(
f"ON check for {service.name}: "
f"current_hour={current_hour}, "
f"allowed_hours={allowed_hours}, "
f"should_fetch={should_fetch}"
)
return should_fetch
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
try:
interval_hours = int(service.fetch_interval)
if service.last_fetch is None:
return True
# Round down to nearest hour
now = timezone.now().replace(minute=0, second=0, microsecond=0)
last_fetch = service.last_fetch.replace(
minute=0, second=0, microsecond=0
)
hours_since_last = (now - last_fetch).total_seconds() / 3600
should_fetch = hours_since_last >= interval_hours
logger.info(
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

@@ -47,6 +47,34 @@ class SplitTransformationRule(BaseModel):
)
class AddTransformationRule(BaseModel):
type: Literal["add"]
field: str = Field(..., description="Field to add to the source value")
absolute_values: bool = Field(
default=False, description="Use absolute values for addition"
)
thousand_separator: str = Field(
default="", description="Thousand separator character"
)
decimal_separator: str = Field(
default=".", description="Decimal separator character"
)
class SubtractTransformationRule(BaseModel):
type: Literal["subtract"]
field: str = Field(..., description="Field to subtract from the source value")
absolute_values: bool = Field(
default=False, description="Use absolute values for subtraction"
)
thousand_separator: str = Field(
default="", description="Thousand separator character"
)
decimal_separator: str = Field(
default=".", description="Decimal separator character"
)
class CSVImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
@@ -64,6 +92,20 @@ class CSVImportSettings(BaseModel):
]
class ExcelImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
description="If True, errors during import will be logged and skipped",
)
file_type: Literal["xls", "xlsx"]
trigger_transaction_rules: bool = True
importing: Literal[
"transactions", "accounts", "currencies", "categories", "tags", "entities"
]
start_row: int = Field(default=1, description="Where your header is located")
sheets: list[str] | str = "*"
class ColumnMapping(BaseModel):
source: Optional[str] | Optional[list[str]] = Field(
default=None,
@@ -78,6 +120,8 @@ class ColumnMapping(BaseModel):
| HashTransformationRule
| MergeTransformationRule
| SplitTransformationRule
| AddTransformationRule
| SubtractTransformationRule
]
] = Field(default_factory=list)
@@ -86,7 +130,6 @@ class TransactionAccountMapping(ColumnMapping):
target: Literal["account"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionTypeMapping(ColumnMapping):
@@ -105,7 +148,6 @@ class TransactionDateMapping(ColumnMapping):
target: Literal["date"] = Field(..., description="Transaction field to map to")
format: List[str] | str
coerce_to: Literal["date"] = Field("date", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionReferenceDateMapping(ColumnMapping):
@@ -119,7 +161,6 @@ class TransactionReferenceDateMapping(ColumnMapping):
class TransactionAmountMapping(ColumnMapping):
target: Literal["amount"] = Field(..., description="Transaction field to map to")
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionDescriptionMapping(ColumnMapping):
@@ -301,7 +342,7 @@ class CurrencyExchangeMapping(ColumnMapping):
class ImportProfileSchema(BaseModel):
settings: CSVImportSettings
settings: CSVImportSettings | ExcelImportSettings
mapping: Dict[
str,
TransactionAccountMapping

View File

@@ -3,14 +3,16 @@ import hashlib
import logging
import os
import re
from datetime import datetime
from decimal import Decimal
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
from typing import Dict, Any, Literal, Union
import cachalot.api
import openpyxl
import xlrd
import yaml
from cachalot.api import cachalot_disabled
from django.utils import timezone
from openpyxl.utils.exceptions import InvalidFileException
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
@@ -40,7 +42,9 @@ class ImportService:
self.import_run: ImportRun = import_run
self.profile: ImportProfile = import_run.profile
self.config: version_1.ImportProfileSchema = self._load_config()
self.settings: version_1.CSVImportSettings = self.config.settings
self.settings: version_1.CSVImportSettings | version_1.ExcelImportSettings = (
self.config.settings
)
self.deduplication: list[version_1.CompareDeduplicationRule] = (
self.config.deduplication
)
@@ -75,6 +79,13 @@ class ImportService:
self.import_run.logs += log_line
self.import_run.save(update_fields=["logs"])
if level == "info":
logger.info(log_line)
elif level == "warning":
logger.warning(log_line)
elif level == "error":
logger.error(log_line, exc_info=True)
def _update_totals(
self,
field: Literal["total", "processed", "successful", "skipped", "failed"],
@@ -129,9 +140,12 @@ class ImportService:
self.import_run.save(update_fields=["status"])
@staticmethod
def _transform_value(
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
self,
value: str,
mapping: version_1.ColumnMapping,
row: Dict[str, str] = None,
mapped_data: Dict[str, Any] = None,
) -> Any:
transformed = value
@@ -142,8 +156,12 @@ class ImportService:
for field in transform.fields:
if field in row:
values_to_hash.append(str(row[field]))
# Create hash from concatenated values
elif (
field.startswith("__")
and mapped_data
and field[2:] in mapped_data
):
values_to_hash.append(str(mapped_data[field[2:]]))
if values_to_hash:
concatenated = "|".join(values_to_hash)
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
@@ -157,6 +175,7 @@ class ImportService:
transformed = transformed.replace(
transform.pattern, transform.replacement
)
elif transform.type == "regex":
if transform.exclusive:
transformed = re.sub(
@@ -166,16 +185,25 @@ class ImportService:
transformed = re.sub(
transform.pattern, transform.replacement, transformed
)
elif transform.type == "date_format":
transformed = datetime.strptime(
transformed, transform.original_format
).strftime(transform.new_format)
elif transform.type == "merge":
values_to_merge = []
for field in transform.fields:
if field in row:
values_to_merge.append(str(row[field]))
elif (
field.startswith("__")
and mapped_data
and field[2:] in mapped_data
):
values_to_merge.append(str(mapped_data[field[2:]]))
transformed = transform.separator.join(values_to_merge)
elif transform.type == "split":
parts = transformed.split(transform.separator)
if transform.index is not None:
@@ -183,6 +211,38 @@ class ImportService:
else:
transformed = parts
elif transform.type in ["add", "subtract"]:
try:
source_value = Decimal(transformed)
# First check row data, then mapped data if not found
field_value = row.get(transform.field)
if field_value is None and transform.field.startswith("__"):
field_value = mapped_data.get(transform.field[2:])
if field_value is None:
raise KeyError(
f"Field '{transform.field}' not found in row or mapped data"
)
field_value = self._prepare_numeric_value(
str(field_value),
transform.thousand_separator,
transform.decimal_separator,
)
if transform.absolute_values:
source_value = abs(source_value)
field_value = abs(field_value)
if transform.type == "add":
transformed = str(source_value + field_value)
else: # subtract
transformed = str(source_value - field_value)
except (InvalidOperation, KeyError, AttributeError) as e:
logger.warning(
f"Error in {transform.type} transformation: {e}. Values: {transformed}, {transform.field}"
)
return transformed
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
@@ -399,7 +459,7 @@ class ImportService:
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list]:
) -> Union[str, int, bool, Decimal, datetime, list, None]:
if not value:
return None
@@ -434,6 +494,11 @@ class ImportService:
version_1.TransactionReferenceDateMapping,
),
):
if isinstance(value, datetime):
return value.date()
elif isinstance(value, date):
return value
formats = (
mapping.format
if isinstance(mapping.format, list)
@@ -484,28 +549,30 @@ class ImportService:
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
mapped_data = {}
for field, mapping in self.mapping.items():
value = None
if isinstance(mapping.source, str):
value = row.get(mapping.source, None)
if mapping.source in row:
value = row[mapping.source]
elif (
mapping.source.startswith("__")
and mapping.source[2:] in mapped_data
):
value = mapped_data[mapping.source[2:]]
elif isinstance(mapping.source, list):
for source in mapping.source:
value = row.get(source, None)
if value:
if source in row:
value = row[source]
break
elif source.startswith("__") and source[2:] in mapped_data:
value = mapped_data[source[2:]]
break
else:
# If source is None, use None as the initial value
value = None
# Use default_value if value is None
if not value:
if value is None:
value = mapping.default
# Apply transformations
if mapping.transformations:
value = self._transform_value(value, mapping, row)
value = self._transform_value(value, mapping, row, mapped_data)
value = self._coerce_type(value, mapping)
@@ -513,17 +580,29 @@ class ImportService:
raise ValueError(f"Required field {field} is missing")
if value is not None:
# Remove the prefix from the target field
target = mapping.target
if self.settings.importing == "transactions":
mapped_data[target] = value
else:
# Remove the model prefix (e.g., "account_" from "account_name")
field_name = target.split("_", 1)[1]
mapped_data[field_name] = value
return mapped_data
@staticmethod
def _prepare_numeric_value(
value: str, thousand_separator: str, decimal_separator: str
) -> Decimal:
# Remove thousand separators
if thousand_separator:
value = value.replace(thousand_separator, "")
# Replace decimal separator with dot
if decimal_separator != ".":
value = value.replace(decimal_separator, ".")
return Decimal(value)
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
try:
mapped_data = self._map_row(row)
@@ -589,6 +668,151 @@ class ImportService:
for row_number, row in enumerate(reader, start=1):
self._process_row(row, row_number)
def _process_excel(self, file_path):
try:
if self.settings.file_type == "xlsx":
workbook = openpyxl.load_workbook(
file_path, read_only=True, data_only=True
)
sheets_to_process = (
workbook.sheetnames
if self.settings.sheets == "*"
else (
self.settings.sheets
if isinstance(self.settings.sheets, list)
else [self.settings.sheets]
)
)
# Calculate total rows
total_rows = sum(
max(0, workbook[sheet_name].max_row - self.settings.start_row)
for sheet_name in sheets_to_process
if sheet_name in workbook.sheetnames
)
self._update_totals("total", value=total_rows)
# Process sheets
for sheet_name in sheets_to_process:
if sheet_name not in workbook.sheetnames:
self._log(
"warning",
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
)
continue
sheet = workbook[sheet_name]
self._log("info", f"Processing sheet: {sheet_name}")
headers = [
str(cell.value or "") for cell in sheet[self.settings.start_row]
]
for row_number, row in enumerate(
sheet.iter_rows(
min_row=self.settings.start_row + 1, values_only=True
),
start=1,
):
try:
row_data = {
key: str(value) if value is not None else None
for key, value in zip(headers, row)
}
self._process_row(row_data, row_number)
except Exception as e:
if self.settings.skip_errors:
self._log(
"warning",
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
)
self._increment_totals("failed", value=1)
else:
raise
workbook.close()
else: # xls
workbook = xlrd.open_workbook(file_path)
sheets_to_process = (
workbook.sheet_names()
if self.settings.sheets == "*"
else (
self.settings.sheets
if isinstance(self.settings.sheets, list)
else [self.settings.sheets]
)
)
# Calculate total rows
total_rows = sum(
max(
0,
workbook.sheet_by_name(sheet_name).nrows
- self.settings.start_row,
)
for sheet_name in sheets_to_process
if sheet_name in workbook.sheet_names()
)
self._update_totals("total", value=total_rows)
# Process sheets
for sheet_name in sheets_to_process:
if sheet_name not in workbook.sheet_names():
self._log(
"warning",
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
)
continue
sheet = workbook.sheet_by_name(sheet_name)
self._log("info", f"Processing sheet: {sheet_name}")
headers = [
str(sheet.cell_value(self.settings.start_row - 1, col) or "")
for col in range(sheet.ncols)
]
for row_number in range(self.settings.start_row, sheet.nrows):
try:
row_data = {}
for col, key in enumerate(headers):
cell_type = sheet.cell_type(row_number, col)
cell_value = sheet.cell_value(row_number, col)
if cell_type == xlrd.XL_CELL_DATE:
# Convert Excel date to Python datetime
try:
python_date = datetime(
*xlrd.xldate_as_tuple(
cell_value, workbook.datemode
)
)
row_data[key] = python_date
except Exception:
# If date conversion fails, use the original value
row_data[key] = (
str(cell_value)
if cell_value is not None
else None
)
elif cell_value is None:
row_data[key] = None
else:
row_data[key] = str(cell_value)
self._process_row(
row_data, row_number - self.settings.start_row + 1
)
except Exception as e:
if self.settings.skip_errors:
self._log(
"warning",
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
)
self._increment_totals("failed", value=1)
else:
raise
except (InvalidFileException, xlrd.XLRDError) as e:
raise ValueError(
f"Invalid {self.settings.file_type.upper()} file format: {str(e)}"
)
def _validate_file_path(self, file_path: str) -> str:
"""
Validates that the file path is within the allowed temporary directory.
@@ -611,8 +835,10 @@ class ImportService:
self._log("info", "Starting import process")
try:
if self.settings.file_type == "csv":
if isinstance(self.settings, version_1.CSVImportSettings):
self._process_csv(file_path)
elif isinstance(self.settings, version_1.ExcelImportSettings):
self._process_excel(file_path)
self._update_status("FINISHED")
self._log(
@@ -639,4 +865,3 @@ class ImportService:
self.import_run.finished_at = timezone.now()
self.import_run.save(update_fields=["finished_at"])
cachalot.api.invalidate()

View File

@@ -1,6 +1,5 @@
import logging
import cachalot.api
from procrastinate.contrib.django import app
from apps.import_app.models import ImportRun
@@ -9,13 +8,11 @@ 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)
import_service = ImportServiceV1(import_run)
import_service.process_file(file_path)
cachalot.api.invalidate()
except ImportRun.DoesNotExist:
cachalot.api.invalidate()
raise ValueError(f"ImportRun with id {import_run_id} not found")

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

@@ -1,7 +1,12 @@
from django.contrib import admin
from apps.rules.models import TransactionRule, TransactionRuleAction
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
# Register your models here.
admin.site.register(TransactionRule)
admin.site.register(TransactionRuleAction)
admin.site.register(UpdateOrCreateTransactionRuleAction)

View File

@@ -1,15 +1,15 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Row, Column
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from apps.rules.models import TransactionRule
from apps.rules.models import TransactionRuleAction
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.models import TransactionRuleAction
class TransactionRuleForm(forms.ModelForm):
@@ -123,3 +123,255 @@ class TransactionRuleActionForm(forms.ModelForm):
if commit:
instance.save()
return instance
class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
class Meta:
model = UpdateOrCreateTransactionRuleAction
exclude = ("rule",)
widgets = {
"search_account_operator": TomSelect(clear_button=False),
"search_type_operator": TomSelect(clear_button=False),
"search_is_paid_operator": TomSelect(clear_button=False),
"search_date_operator": TomSelect(clear_button=False),
"search_reference_date_operator": TomSelect(clear_button=False),
"search_amount_operator": TomSelect(clear_button=False),
"search_description_operator": TomSelect(clear_button=False),
"search_notes_operator": TomSelect(clear_button=False),
"search_category_operator": TomSelect(clear_button=False),
"search_internal_note_operator": TomSelect(clear_button=False),
"search_internal_id_operator": TomSelect(clear_button=False),
}
labels = {
"search_account_operator": _("Operator"),
"search_type_operator": _("Operator"),
"search_is_paid_operator": _("Operator"),
"search_date_operator": _("Operator"),
"search_reference_date_operator": _("Operator"),
"search_amount_operator": _("Operator"),
"search_description_operator": _("Operator"),
"search_notes_operator": _("Operator"),
"search_category_operator": _("Operator"),
"search_internal_note_operator": _("Operator"),
"search_internal_id_operator": _("Operator"),
"search_tags_operator": _("Operator"),
"search_entities_operator": _("Operator"),
"search_account": _("Account"),
"search_type": _("Type"),
"search_is_paid": _("Paid"),
"search_date": _("Date"),
"search_reference_date": _("Reference Date"),
"search_amount": _("Amount"),
"search_description": _("Description"),
"search_notes": _("Notes"),
"search_category": _("Category"),
"search_internal_note": _("Internal Note"),
"search_internal_id": _("Internal ID"),
"search_tags": _("Tags"),
"search_entities": _("Entities"),
"set_account": _("Account"),
"set_type": _("Type"),
"set_is_paid": _("Paid"),
"set_date": _("Date"),
"set_reference_date": _("Reference Date"),
"set_amount": _("Amount"),
"set_description": _("Description"),
"set_tags": _("Tags"),
"set_entities": _("Entities"),
"set_notes": _("Notes"),
"set_category": _("Category"),
"set_internal_note": _("Internal Note"),
"set_internal_id": _("Internal ID"),
}
def __init__(self, *args, **kwargs):
self.rule = kwargs.pop("rule", None)
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
BS5Accordion(
AccordionGroup(
_("Search Criteria"),
Field("filter", rows=1),
Row(
Column(
Field("search_type_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_type", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_is_paid_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_is_paid", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_account_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_account", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_entities_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_entities", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_date_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_date", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_reference_date_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_reference_date", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_description_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_description", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_amount_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_amount", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_category_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_category", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_tags_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_tags", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_notes_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_notes", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_internal_note_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_internal_note", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_internal_id_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_internal_id", rows=1),
css_class="form-group col-md-8",
),
),
active=True,
),
AccordionGroup(
_("Set Values"),
Field("set_type", rows=1),
Field("set_is_paid", rows=1),
Field("set_account", rows=1),
Field("set_entities", rows=1),
Field("set_date", rows=1),
Field("set_reference_date", rows=1),
Field("set_description", rows=1),
Field("set_amount", rows=1),
Field("set_category", rows=1),
Field("set_tags", rows=1),
Field("set_notes", rows=1),
Field("set_internal_note", rows=1),
Field("set_internal_id", rows=1),
css_class="mb-3",
active=True,
),
always_open=True,
),
)
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"
),
),
)
def save(self, commit=True):
instance = super().save(commit=False)
instance.rule = self.rule
if commit:
instance.save()
return instance

View File

@@ -0,0 +1,60 @@
# Generated by Django 5.1.5 on 2025-02-08 03:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0005_alter_transactionruleaction_rule'),
]
operations = [
migrations.CreateModel(
name='UpdateOrCreateTransactionRuleAction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('search_account', models.TextField(blank=True, help_text='Expression to match transaction account (ID or name)', verbose_name='Search Account')),
('search_account_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Account Operator')),
('search_type', models.TextField(blank=True, help_text="Expression to match transaction type ('IN' or 'EX')", verbose_name='Search Type')),
('search_type_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Type Operator')),
('search_is_paid', models.TextField(blank=True, help_text='Expression to match transaction paid status', verbose_name='Search Is Paid')),
('search_is_paid_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Is Paid Operator')),
('search_date', models.TextField(blank=True, help_text='Expression to match transaction date', verbose_name='Search Date')),
('search_date_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Date Operator')),
('search_reference_date', models.TextField(blank=True, help_text='Expression to match transaction reference date', verbose_name='Search Reference Date')),
('search_reference_date_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Reference Date Operator')),
('search_amount', models.TextField(blank=True, help_text='Expression to match transaction amount', verbose_name='Search Amount')),
('search_amount_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Amount Operator')),
('search_description', models.TextField(blank=True, help_text='Expression to match transaction description', verbose_name='Search Description')),
('search_description_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Description Operator')),
('search_notes', models.TextField(blank=True, help_text='Expression to match transaction notes', verbose_name='Search Notes')),
('search_notes_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Notes Operator')),
('search_category', models.TextField(blank=True, help_text='Expression to match transaction category (ID or name)', verbose_name='Search Category')),
('search_category_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Category Operator')),
('search_internal_note', models.TextField(blank=True, help_text='Expression to match transaction internal note', verbose_name='Search Internal Note')),
('search_internal_note_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Internal Note Operator')),
('search_internal_id', models.TextField(blank=True, help_text='Expression to match transaction internal ID', verbose_name='Search Internal ID')),
('search_internal_id_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Internal ID Operator')),
('set_account', models.TextField(blank=True, help_text='Expression for account to set (ID or name)', verbose_name='Set Account')),
('set_type', models.TextField(blank=True, help_text="Expression for type to set ('IN' or 'EX')", verbose_name='Set Type')),
('set_is_paid', models.TextField(blank=True, help_text='Expression for paid status to set', verbose_name='Set Is Paid')),
('set_date', models.TextField(blank=True, help_text='Expression for date to set', verbose_name='Set Date')),
('set_reference_date', models.TextField(blank=True, help_text='Expression for reference date to set', verbose_name='Set Reference Date')),
('set_amount', models.TextField(blank=True, help_text='Expression for amount to set', verbose_name='Set Amount')),
('set_description', models.TextField(blank=True, help_text='Expression for description to set', verbose_name='Set Description')),
('set_notes', models.TextField(blank=True, help_text='Expression for notes to set', verbose_name='Set Notes')),
('set_internal_note', models.TextField(blank=True, help_text='Expression for internal note to set', verbose_name='Set Internal Note')),
('set_internal_id', models.TextField(blank=True, help_text='Expression for internal ID to set', verbose_name='Set Internal ID')),
('set_category', models.TextField(blank=True, help_text='Expression for category to set (ID or name)', verbose_name='Set Category')),
('set_tags', models.TextField(blank=True, help_text='Expression for tags to set (list of IDs or names)', verbose_name='Set Tags')),
('set_entities', models.TextField(blank=True, help_text='Expression for entities to set (list of IDs or names)', verbose_name='Set Entities')),
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='update_or_create_transaction_actions', to='rules.transactionrule', verbose_name='Rule')),
],
options={
'verbose_name': 'pdate or Create Transaction Action',
'verbose_name_plural': 'pdate or Create Transaction Action Actions',
},
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.5 on 2025-02-08 04:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0006_updateorcreatetransactionruleaction'),
]
operations = [
migrations.AlterModelOptions(
name='updateorcreatetransactionruleaction',
options={'verbose_name': 'Update or Create Transaction Action', 'verbose_name_plural': 'Update or Create Transaction Action Actions'},
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='filter',
field=models.TextField(blank=True, help_text='Generic expression to enable or disable execution. Should evaluate to True or False', verbose_name='Filter'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.5 on 2025-02-08 06:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0007_alter_updateorcreatetransactionruleaction_options_and_more'),
]
operations = [
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_entities',
field=models.TextField(blank=True, help_text='Expression to match transaction entities (list of IDs or names)', verbose_name='Search Entities'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_entities_operator',
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Entities Operator'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_tags',
field=models.TextField(blank=True, help_text='Expression to match transaction tags (list of IDs or names)', verbose_name='Search Tags'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_tags_operator',
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Tags Operator'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.5 on 2025-02-08 06:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('rules', '0008_updateorcreatetransactionruleaction_search_entities_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='transactionrule',
options={'verbose_name': 'Transaction rule', 'verbose_name_plural': 'Transaction rules'},
),
migrations.AlterModelOptions(
name='transactionruleaction',
options={'verbose_name': 'Edit transaction action', 'verbose_name_plural': 'Edit transaction actions'},
),
migrations.AlterModelOptions(
name='updateorcreatetransactionruleaction',
options={'verbose_name': 'Update or create transaction action', 'verbose_name_plural': 'Update or create transaction actions'},
),
]

View File

@@ -0,0 +1,138 @@
# Generated by Django 5.1.6 on 2025-02-09 20:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0009_alter_transactionrule_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_account',
field=models.TextField(blank=True, verbose_name='Search Account'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_amount',
field=models.TextField(blank=True, verbose_name='Search Amount'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_category',
field=models.TextField(blank=True, verbose_name='Search Category'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_description',
field=models.TextField(blank=True, verbose_name='Search Description'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_entities',
field=models.TextField(blank=True, verbose_name='Search Entities'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_internal_id',
field=models.TextField(blank=True, verbose_name='Search Internal ID'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_internal_note',
field=models.TextField(blank=True, verbose_name='Search Internal Note'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_is_paid',
field=models.TextField(blank=True, verbose_name='Search Is Paid'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_notes',
field=models.TextField(blank=True, verbose_name='Search Notes'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_reference_date',
field=models.TextField(blank=True, verbose_name='Search Reference Date'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_tags',
field=models.TextField(blank=True, verbose_name='Search Tags'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='search_type',
field=models.TextField(blank=True, verbose_name='Search Type'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_account',
field=models.TextField(blank=True, verbose_name='Account'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_amount',
field=models.TextField(blank=True, verbose_name='Amount'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_category',
field=models.TextField(blank=True, verbose_name='Category'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_date',
field=models.TextField(blank=True, verbose_name='Date'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_description',
field=models.TextField(blank=True, verbose_name='Description'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_entities',
field=models.TextField(blank=True, verbose_name='Entities'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_internal_id',
field=models.TextField(blank=True, verbose_name='Internal ID'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_internal_note',
field=models.TextField(blank=True, verbose_name='Internal Note'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_is_paid',
field=models.TextField(blank=True, verbose_name='Is Paid'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_notes',
field=models.TextField(blank=True, verbose_name='Notes'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_reference_date',
field=models.TextField(blank=True, verbose_name='Reference Date'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_tags',
field=models.TextField(blank=True, verbose_name='Tags'),
),
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_type',
field=models.TextField(blank=True, verbose_name='Type'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-02-09 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0010_alter_updateorcreatetransactionruleaction_search_account_and_more'),
]
operations = [
migrations.AlterField(
model_name='updateorcreatetransactionruleaction',
name='set_is_paid',
field=models.TextField(blank=True, verbose_name='Paid'),
),
]

View File

@@ -1,4 +1,5 @@
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@@ -10,6 +11,10 @@ class TransactionRule(models.Model):
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger"))
class Meta:
verbose_name = _("Transaction rule")
verbose_name_plural = _("Transaction rules")
def __str__(self):
return self.name
@@ -45,4 +50,350 @@ class TransactionRuleAction(models.Model):
return f"{self.rule} - {self.field} - {self.value}"
class Meta:
verbose_name = _("Edit transaction action")
verbose_name_plural = _("Edit transaction actions")
unique_together = (("rule", "field"),)
class UpdateOrCreateTransactionRuleAction(models.Model):
"""
Will attempt to find and update latest matching transaction, or create new if none found.
"""
class SearchOperator(models.TextChoices):
EXACT = "exact", _("is exactly")
CONTAINS = "contains", _("contains")
STARTSWITH = "startswith", _("starts with")
ENDSWITH = "endswith", _("ends with")
EQ = "eq", _("equals")
GT = "gt", _("greater than")
LT = "lt", _("less than")
GTE = "gte", _("greater than or equal")
LTE = "lte", _("less than or equal")
rule = models.ForeignKey(
TransactionRule,
on_delete=models.CASCADE,
related_name="update_or_create_transaction_actions",
verbose_name=_("Rule"),
)
filter = models.TextField(
verbose_name=_("Filter"),
blank=True,
help_text=_(
"Generic expression to enable or disable execution. Should evaluate to True or False"
),
)
# Search fields with operators
search_account = models.TextField(
verbose_name="Search Account",
blank=True,
)
search_account_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Account Operator",
)
search_type = models.TextField(
verbose_name="Search Type",
blank=True,
)
search_type_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Type Operator",
)
search_is_paid = models.TextField(
verbose_name="Search Is Paid",
blank=True,
)
search_is_paid_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Is Paid Operator",
)
search_date = models.TextField(
verbose_name="Search Date",
blank=True,
help_text="Expression to match transaction date",
)
search_date_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Date Operator",
)
search_reference_date = models.TextField(
verbose_name="Search Reference Date",
blank=True,
)
search_reference_date_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Reference Date Operator",
)
search_amount = models.TextField(
verbose_name="Search Amount",
blank=True,
)
search_amount_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Amount Operator",
)
search_description = models.TextField(
verbose_name="Search Description",
blank=True,
)
search_description_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name="Description Operator",
)
search_notes = models.TextField(
verbose_name="Search Notes",
blank=True,
)
search_notes_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name="Notes Operator",
)
search_category = models.TextField(
verbose_name="Search Category",
blank=True,
)
search_category_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Category Operator",
)
search_tags = models.TextField(
verbose_name="Search Tags",
blank=True,
)
search_tags_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name="Tags Operator",
)
search_entities = models.TextField(
verbose_name="Search Entities",
blank=True,
)
search_entities_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.CONTAINS,
verbose_name="Entities Operator",
)
search_internal_note = models.TextField(
verbose_name="Search Internal Note",
blank=True,
)
search_internal_note_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Internal Note Operator",
)
search_internal_id = models.TextField(
verbose_name="Search Internal ID",
blank=True,
)
search_internal_id_operator = models.CharField(
max_length=10,
choices=SearchOperator.choices,
default=SearchOperator.EXACT,
verbose_name="Internal ID Operator",
)
# Set fields
set_account = models.TextField(
verbose_name=_("Account"),
blank=True,
)
set_type = models.TextField(
verbose_name=_("Type"),
blank=True,
)
set_is_paid = models.TextField(
verbose_name=_("Paid"),
blank=True,
)
set_date = models.TextField(
verbose_name=_("Date"),
blank=True,
)
set_reference_date = models.TextField(
verbose_name=_("Reference Date"),
blank=True,
)
set_amount = models.TextField(
verbose_name=_("Amount"),
blank=True,
)
set_description = models.TextField(
verbose_name=_("Description"),
blank=True,
)
set_notes = models.TextField(
verbose_name=_("Notes"),
blank=True,
)
set_internal_note = models.TextField(
verbose_name=_("Internal Note"),
blank=True,
)
set_internal_id = models.TextField(
verbose_name=_("Internal ID"),
blank=True,
)
set_entities = models.TextField(
verbose_name=_("Entities"),
blank=True,
)
set_category = models.TextField(
verbose_name=_("Category"),
blank=True,
)
set_tags = models.TextField(
verbose_name=_("Tags"),
blank=True,
)
class Meta:
verbose_name = _("Update or create transaction action")
verbose_name_plural = _("Update or create transaction actions")
def __str__(self):
return f"Update or create transaction action for {self.rule}"
def build_search_query(self, simple):
"""Builds Q objects based on search fields and their operators"""
search_query = Q()
def add_to_query(field_name, value, operator):
if isinstance(value, (int, str)):
lookup = f"{field_name}__{operator}"
return Q(**{lookup: value})
return Q()
if self.search_account:
value = simple.eval(self.search_account)
if isinstance(value, int):
search_query &= add_to_query(
"account_id", value, self.search_account_operator
)
else:
search_query &= add_to_query(
"account__name", value, self.search_account_operator
)
if self.search_type:
value = simple.eval(self.search_type)
search_query &= add_to_query("type", value, self.search_type_operator)
if self.search_is_paid:
value = simple.eval(self.search_is_paid)
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
if self.search_date:
value = simple.eval(self.search_date)
search_query &= add_to_query("date", value, self.search_date_operator)
if self.search_reference_date:
value = simple.eval(self.search_reference_date)
search_query &= add_to_query(
"reference_date", value, self.search_reference_date_operator
)
if self.search_amount:
value = simple.eval(self.search_amount)
search_query &= add_to_query("amount", value, self.search_amount_operator)
if self.search_description:
value = simple.eval(self.search_description)
search_query &= add_to_query(
"description", value, self.search_description_operator
)
if self.search_notes:
value = simple.eval(self.search_notes)
search_query &= add_to_query("notes", value, self.search_notes_operator)
if self.search_internal_note:
value = simple.eval(self.search_internal_note)
search_query &= add_to_query(
"internal_note", value, self.search_internal_note_operator
)
if self.search_internal_id:
value = simple.eval(self.search_internal_id)
search_query &= add_to_query(
"internal_id", value, self.search_internal_id_operator
)
if self.search_category:
value = simple.eval(self.search_category)
if isinstance(value, int):
search_query &= add_to_query(
"category_id", value, self.search_category_operator
)
else:
search_query &= add_to_query(
"category__name", value, self.search_category_operator
)
if self.search_tags:
tags_value = simple.eval(self.search_tags)
if isinstance(tags_value, (list, tuple)):
for tag in tags_value:
if isinstance(tag, int):
search_query &= Q(tags__id=tag)
else:
search_query &= Q(tags__name__iexact=tag)
elif isinstance(tags_value, (int, str)):
if isinstance(tags_value, int):
search_query &= Q(tags__id=tags_value)
else:
search_query &= Q(tags__name__iexact=tags_value)
if self.search_entities:
entities_value = simple.eval(self.search_entities)
if isinstance(entities_value, (list, tuple)):
for entity in entities_value:
if isinstance(entity, int):
search_query &= Q(entities__id=entity)
else:
search_query &= Q(entities__name__iexact=entity)
elif isinstance(entities_value, (int, str)):
if isinstance(entities_value, int):
search_query &= Q(entities__id=entities_value)
else:
search_query &= Q(entities__name__iexact=entities_value)
return search_query

View File

@@ -1,4 +1,6 @@
import decimal
import logging
from datetime import datetime, date
from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta
@@ -6,7 +8,10 @@ from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes
from apps.accounts.models import Account
from apps.rules.models import TransactionRule, TransactionRuleAction
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
)
from apps.transactions.models import (
Transaction,
TransactionCategory,
@@ -14,148 +19,342 @@ from apps.transactions.models import (
TransactionEntity,
)
logger = logging.getLogger(__name__)
@app.task
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id: int,
signal,
):
try:
with cachalot_disabled():
instance = Transaction.objects.get(id=instance_id)
context = {
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
functions = {
"relativedelta": relativedelta,
"str": str,
"int": int,
"float": float,
"decimal": decimal.Decimal,
"datetime": datetime,
"date": date,
}
functions = {"relativedelta": relativedelta}
simple = EvalWithCompoundTypes(names=context, functions=functions)
simple = EvalWithCompoundTypes(
names=_get_names(instance), functions=functions
)
if signal == "transaction_created":
rules = TransactionRule.objects.filter(active=True, on_create=True)
rules = TransactionRule.objects.filter(
active=True, on_create=True
).order_by("id")
elif signal == "transaction_updated":
rules = TransactionRule.objects.filter(active=True, on_update=True)
rules = TransactionRule.objects.filter(
active=True, on_update=True
).order_by("id")
else:
rules = TransactionRule.objects.filter(active=True)
rules = TransactionRule.objects.filter(active=True).order_by("id")
for rule in rules:
if simple.eval(rule.trigger):
for action in rule.transaction_actions.all():
if action.field in [
TransactionRuleAction.Field.type,
TransactionRuleAction.Field.is_paid,
TransactionRuleAction.Field.date,
TransactionRuleAction.Field.reference_date,
TransactionRuleAction.Field.amount,
TransactionRuleAction.Field.description,
TransactionRuleAction.Field.notes,
]:
setattr(
instance,
action.field,
simple.eval(action.value),
try:
instance = _process_edit_transaction_action(
instance=instance, action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
# else:
# simple.names.update(_get_names(instance))
# instance.save()
simple.names.update(_get_names(instance))
instance.save()
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
)
elif action.field == TransactionRuleAction.Field.account:
value = simple.eval(action.value)
if isinstance(value, int):
account = Account.objects.get(id=value)
instance.account = account
elif isinstance(value, str):
account = Account.objects.filter(name=value).first()
instance.account = account
elif action.field == TransactionRuleAction.Field.category:
value = simple.eval(action.value)
if isinstance(value, int):
category = TransactionCategory.objects.get(id=value)
instance.category = category
elif isinstance(value, str):
category = TransactionCategory.objects.get(name=value)
instance.category = category
elif action.field == TransactionRuleAction.Field.tags:
value = simple.eval(action.value)
if isinstance(value, list):
# Clear existing tags
instance.tags.clear()
for tag_value in value:
if isinstance(tag_value, int):
tag = TransactionTag.objects.get(id=tag_value)
instance.tags.add(tag)
elif isinstance(tag_value, str):
tag = TransactionTag.objects.get(name=tag_value)
instance.tags.add(tag)
elif isinstance(value, (int, str)):
# If a single value is provided, treat it as a single tag
instance.tags.clear()
if isinstance(value, int):
tag = TransactionTag.objects.get(id=value)
else:
tag = TransactionTag.objects.get(name=value)
instance.tags.add(tag)
elif action.field == TransactionRuleAction.Field.entities:
value = simple.eval(action.value)
if isinstance(value, list):
# Clear existing entities
instance.entities.clear()
for entity_value in value:
if isinstance(entity_value, int):
entity = TransactionEntity.objects.get(
id=entity_value
)
instance.entities.add(entity)
elif isinstance(entity_value, str):
entity = TransactionEntity.objects.get(
name=entity_value
)
instance.entities.add(entity)
elif isinstance(value, (int, str)):
# If a single value is provided, treat it as a single entity
instance.entities.clear()
if isinstance(value, int):
entity = TransactionEntity.objects.get(id=value)
else:
entity = TransactionEntity.objects.get(name=value)
instance.entities.add(entity)
instance.save()
except Exception as e:
logger.error(
"Error while executing 'check_for_transaction_rules' task",
exc_info=True,
)
raise e
def _get_names(instance):
return {
"id": instance.id,
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
"internal_note": instance.internal_note,
"internal_id": instance.internal_id,
}
def _process_update_or_create_transaction_action(action, simple_eval):
"""Helper to process a single linked transaction action"""
# Build search query using the helper method
search_query = action.build_search_query(simple_eval)
# Find latest matching transaction or create new
if search_query:
transaction = (
Transaction.objects.filter(search_query).order_by("-date", "-id").first()
)
else:
transaction = None
if not transaction:
transaction = Transaction()
simple_eval.names.update(
{
"my_account_name": (transaction.account.name if transaction.id else None),
"my_account_id": transaction.account.id if transaction.id else None,
"my_account_group_name": (
transaction.account.group.name
if transaction.id and transaction.account.group
else None
),
"my_account_group_id": (
transaction.account.group.id
if transaction.id and transaction.account.group
else None
),
"my_is_asset_account": (
transaction.account.is_asset if transaction.id else None
),
"my_is_archived_account": (
transaction.account.is_archived if transaction.id else None
),
"my_category_name": (
transaction.category.name if transaction.category else None
),
"my_category_id": transaction.category.id if transaction.category else None,
"my_tag_names": (
[x.name for x in transaction.tags.all()] if transaction.id else []
),
"my_tag_ids": (
[x.id for x in transaction.tags.all()] if transaction.id else []
),
"my_entities_names": (
[x.name for x in transaction.entities.all()] if transaction.id else []
),
"my_entities_ids": (
[x.id for x in transaction.entities.all()] if transaction.id else []
),
"my_is_expense": transaction.type == Transaction.Type.EXPENSE,
"my_is_income": transaction.type == Transaction.Type.INCOME,
"my_is_paid": transaction.is_paid,
"my_description": transaction.description,
"my_amount": transaction.amount or 0,
"my_notes": transaction.notes,
"my_date": transaction.date,
"my_reference_date": transaction.reference_date,
"my_internal_note": transaction.internal_note,
"my_internal_id": transaction.reference_date,
}
)
if action.filter:
value = simple_eval.eval(action.filter)
if not value:
return # Short-circuit execution if filter evaluates to false
# Set fields if provided
if action.set_account:
value = simple_eval.eval(action.set_account)
if isinstance(value, int):
transaction.account = Account.objects.get(id=value)
else:
transaction.account = Account.objects.get(name=value)
if action.set_type:
transaction.type = simple_eval.eval(action.set_type)
if action.set_is_paid:
transaction.is_paid = simple_eval.eval(action.set_is_paid)
if action.set_date:
transaction.date = simple_eval.eval(action.set_date)
if action.set_reference_date:
transaction.reference_date = simple_eval.eval(action.set_reference_date)
if action.set_amount:
transaction.amount = simple_eval.eval(action.set_amount)
if action.set_description:
transaction.description = simple_eval.eval(action.set_description)
if action.set_internal_note:
transaction.internal_note = simple_eval.eval(action.set_internal_note)
if action.set_internal_id:
transaction.internal_id = simple_eval.eval(action.set_internal_id)
if action.set_notes:
transaction.notes = simple_eval.eval(action.set_notes)
if action.set_category:
value = simple_eval.eval(action.set_category)
if isinstance(value, int):
transaction.category = TransactionCategory.objects.get(id=value)
else:
transaction.category = TransactionCategory.objects.get(name=value)
transaction.save()
# Handle M2M fields after save
if action.set_tags:
tags_value = simple_eval.eval(action.set_tags)
transaction.tags.clear()
if isinstance(tags_value, (list, tuple)):
for tag in tags_value:
if isinstance(tag, int):
transaction.tags.add(TransactionTag.objects.get(id=tag))
else:
transaction.tags.add(TransactionTag.objects.get(name=tag))
elif isinstance(tags_value, (int, str)):
if isinstance(tags_value, int):
transaction.tags.add(TransactionTag.objects.get(id=tags_value))
else:
transaction.tags.add(TransactionTag.objects.get(name=tags_value))
if action.set_entities:
entities_value = simple_eval.eval(action.set_entities)
transaction.entities.clear()
if isinstance(entities_value, (list, tuple)):
for entity in entities_value:
if isinstance(entity, int):
transaction.entities.add(TransactionEntity.objects.get(id=entity))
else:
transaction.entities.add(TransactionEntity.objects.get(name=entity))
elif isinstance(entities_value, (int, str)):
if isinstance(entities_value, int):
transaction.entities.add(
TransactionEntity.objects.get(id=entities_value)
)
else:
transaction.entities.add(
TransactionEntity.objects.get(name=entities_value)
)
def _process_edit_transaction_action(instance, action, simple_eval) -> Transaction:
if action.field in [
TransactionRuleAction.Field.type,
TransactionRuleAction.Field.is_paid,
TransactionRuleAction.Field.date,
TransactionRuleAction.Field.reference_date,
TransactionRuleAction.Field.amount,
TransactionRuleAction.Field.description,
TransactionRuleAction.Field.notes,
]:
setattr(
instance,
action.field,
simple_eval.eval(action.value),
)
elif action.field == TransactionRuleAction.Field.account:
value = simple_eval.eval(action.value)
if isinstance(value, int):
account = Account.objects.get(id=value)
instance.account = account
elif isinstance(value, str):
account = Account.objects.filter(name=value).first()
instance.account = account
elif action.field == TransactionRuleAction.Field.category:
value = simple_eval.eval(action.value)
if isinstance(value, int):
category = TransactionCategory.objects.get(id=value)
instance.category = category
elif isinstance(value, str):
category = TransactionCategory.objects.get(name=value)
instance.category = category
elif action.field == TransactionRuleAction.Field.tags:
value = simple_eval.eval(action.value)
if isinstance(value, list):
# Clear existing tags
instance.tags.clear()
for tag_value in value:
if isinstance(tag_value, int):
tag = TransactionTag.objects.get(id=tag_value)
instance.tags.add(tag)
elif isinstance(tag_value, str):
tag = TransactionTag.objects.get(name=tag_value)
instance.tags.add(tag)
elif isinstance(value, (int, str)):
# If a single value is provided, treat it as a single tag
instance.tags.clear()
if isinstance(value, int):
tag = TransactionTag.objects.get(id=value)
else:
tag = TransactionTag.objects.get(name=value)
instance.tags.add(tag)
elif action.field == TransactionRuleAction.Field.entities:
value = simple_eval.eval(action.value)
if isinstance(value, list):
# Clear existing entities
instance.entities.clear()
for entity_value in value:
if isinstance(entity_value, int):
entity = TransactionEntity.objects.get(id=entity_value)
instance.entities.add(entity)
elif isinstance(entity_value, str):
entity = TransactionEntity.objects.get(name=entity_value)
instance.entities.add(entity)
elif isinstance(value, (int, str)):
# If a single value is provided, treat it as a single entity
instance.entities.clear()
if isinstance(value, int):
entity = TransactionEntity.objects.get(id=value)
else:
entity = TransactionEntity.objects.get(name=value)
instance.entities.add(entity)
return instance

View File

@@ -38,18 +38,33 @@ urlpatterns = [
name="transaction_rule_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/action/add/",
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
views.transaction_rule_action_add,
name="transaction_rule_action_add",
),
path(
"rules/transaction/action/<int:transaction_rule_action_id>/edit/",
"rules/transaction/transaction-action/<int:transaction_rule_action_id>/edit/",
views.transaction_rule_action_edit,
name="transaction_rule_action_edit",
),
path(
"rules/transaction/action/<int:transaction_rule_action_id>/delete/",
"rules/transaction/transaction-action/<int:transaction_rule_action_id>/delete/",
views.transaction_rule_action_delete,
name="transaction_rule_action_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/update-or-create-transaction-action/add/",
views.update_or_create_transaction_rule_action_add,
name="update_or_create_transaction_rule_action_add",
),
path(
"rules/transaction/update-or-create-transaction-action/<int:pk>/edit/",
views.update_or_create_transaction_rule_action_edit,
name="update_or_create_transaction_rule_action_edit",
),
path(
"rules/transaction/update-or-create-transaction-action/<int:pk>/delete/",
views.update_or_create_transaction_rule_action_delete,
name="update_or_create_transaction_rule_action_delete",
),
]

View File

@@ -6,8 +6,16 @@ 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.rules.forms import TransactionRuleForm, TransactionRuleActionForm
from apps.rules.models import TransactionRule, TransactionRuleAction
from apps.rules.forms import (
TransactionRuleForm,
TransactionRuleActionForm,
UpdateOrCreateTransactionRuleActionForm,
)
from apps.rules.models import (
TransactionRule,
TransactionRuleAction,
UpdateOrCreateTransactionRuleAction,
)
@login_required
@@ -60,10 +68,15 @@ def transaction_rule_add(request, **kwargs):
if request.method == "POST":
form = TransactionRuleForm(request.POST)
if form.is_valid():
instance = form.save()
form.save()
messages.success(request, _("Rule added successfully"))
return redirect("transaction_rule_action_add", instance.id)
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = TransactionRuleForm()
@@ -215,3 +228,88 @@ def transaction_rule_action_delete(request, transaction_rule_action_id):
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
if request.method == "POST":
form = UpdateOrCreateTransactionRuleActionForm(
request.POST, rule=transaction_rule
)
if form.is_valid():
form.save()
messages.success(
request, _("Update or Create Transaction action added successfully")
)
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = UpdateOrCreateTransactionRuleActionForm(rule=transaction_rule)
return render(
request,
"rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html",
{"form": form, "transaction_rule_id": transaction_rule_id},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_edit(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
transaction_rule = linked_action.rule
if request.method == "POST":
form = UpdateOrCreateTransactionRuleActionForm(
request.POST, instance=linked_action, rule=transaction_rule
)
if form.is_valid():
form.save()
messages.success(
request, _("Update or Create Transaction action updated successfully")
)
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = UpdateOrCreateTransactionRuleActionForm(
instance=linked_action, rule=transaction_rule
)
return render(
request,
"rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html",
{"form": form, "action": linked_action},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def update_or_create_transaction_rule_action_delete(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
linked_action.delete()
messages.success(
request, _("Update or Create Transaction action deleted successfully")
)
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)

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

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _, ngettext_lazy
from apps.common.decorators.htmx import only_htmx
from apps.transactions.models import Transaction
from apps.rules.signals import transaction_updated
@only_htmx
@@ -17,6 +18,9 @@ def bulk_pay_transactions(request):
count = transactions.count()
transactions.update(is_paid=True)
for transaction in transactions:
transaction_updated.send(sender=transaction)
messages.success(
request,
ngettext_lazy(
@@ -41,6 +45,9 @@ def bulk_unpay_transactions(request):
count = transactions.count()
transactions.update(is_paid=False)
for transaction in transactions:
transaction_updated.send(sender=transaction)
messages.success(
request,
ngettext_lazy(
@@ -61,7 +68,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 +88,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"])
@@ -298,6 +316,7 @@ def transaction_pay(request, transaction_id):
new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid
transaction.save()
transaction_updated.send(sender=transaction)
response = render(
request,
@@ -314,9 +333,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 +405,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,
},
)

View File

@@ -0,0 +1,46 @@
settings:
file_type: xls
skip_errors: true
trigger_transaction_rules: true
importing: transactions
start_row: 1
sheets: "*"
mapping:
account:
target: account
default: "<TU NOMBRE DE CUENTA>"
type: name
type:
source: Importe
target: type
detection_method: sign
internal_id:
target: internal_id
transformations:
- type: hash
fields: ["Fecha", "Concepto", "Importe", "Saldo"]
date:
source: "Fecha"
target: date
format: "%d-%m-%Y"
description:
source: Concepto
target: description
amount:
source: Importe
target: amount
is_paid:
target: is_paid
detection_method: always_paid
deduplication:
- type: compare
fields:
- internal_id
match_type: strict

View File

@@ -0,0 +1,7 @@
{
"author": "eitchtee,Pablo Hinojosa",
"description": "Importe sus movimientos desde su cuenta de Cajamar",
"schema_version": 1,
"name": "Grupo Cooperativo Cajamar",
"message": "Cambia '<TU NOMBRE DE CUENTA>' por el nombre de tu cuenta de Cajamar dentro de WYGIWYH"
}

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

@@ -57,9 +57,8 @@
</td>
<td class="col">{{ account.name }}</td>
<td class="col">{{ account.group.name }}</td>
<td class="col">{{ account.currency }} ({{ account.currency.code }})</td>
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }} (
{{ account.exchange_currency.code }}){% else %}-{% endif %}</td>
<td class="col">{{ account.currency }}</td>
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }}{% else %}-{% endif %}</td>
<td class="col">{% if account.is_asset %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
<td class="col">{% if account.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
</tr>

View File

@@ -1,139 +1,177 @@
{% load markdown %}
{% 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>
{% endif %}
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
<div class="transaction">
<div class="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>
{% 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">
{% 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">
<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>
{# 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 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">
{% 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>
{% 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>
{% 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 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>
{# 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>
{% 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 class="col-lg-3 col-12 text-lg-end align-self-end">
<div class="main-amount mb-2 mb-lg-0">
<c-amount.display
:amount="transaction.amount"
:prefix="transaction.account.currency.prefix"
:suffix="transaction.account.currency.suffix"
:decimal_places="transaction.account.currency.decimal_places"
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</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 %}
{% endwith %}
<div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.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="main-amount mb-2 mb-lg-0">
<c-amount.display
:amount="transaction.amount"
:prefix="transaction.account.currency.prefix"
:suffix="transaction.account.currency.suffix"
:decimal_places="transaction.account.currency.decimal_places"
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</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 %}
{% 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>
{# 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>
</div>

View File

@@ -0,0 +1,199 @@
{% 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>
{% if account.income_projected != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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>
{% if account.expense_projected != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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>
{% if account.income_current != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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>
{% if account.expense_current != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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,193 @@
{% 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>
{% if currency.income_projected != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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>
{% if currency.expense_projected != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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>
{% if currency.income_current != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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>
{% if currency.expense_current != 0 %}
<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>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</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

@@ -4,9 +4,9 @@
<div id="transactions-list">
{% for x in transactions_by_date %}
<div id="{{ x.grouper|slugify }}"
_="on htmx:afterSettle from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
<div class="mt-3 mb-1 w-100 tw-text-base border-bottom bg-body">
<div id="{{ x.grouper|slugify }}" class="transactions-divider"
_="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
<div class="mt-3 mb-1 w-100 tw-text-base border-bottom bg-body transactions-divider-title">
<a class="text-decoration-none d-inline-block w-100"
role="button"
data-bs-toggle="collapse"
@@ -17,15 +17,21 @@
{{ x.grouper }}
</a>
</div>
<div class="collapse" id="c-{{ x.grouper|slugify }}-collapse"
<div class="collapse transactions-divider-collapse" id="c-{{ x.grouper|slugify }}-collapse"
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
on htmx:afterSettle from #transactions
on htmx:afterSettle from #transactions or toggle
set state to sessionStorage.getItem(the closest parent @id)
if state is 'true' or state is null
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
end">
else
remove .show from me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
end
on show
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
<div class="d-flex flex-column">
{% for transaction in x.list %}
<c-transaction.item

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,12 +252,14 @@
</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 %}
<p class="tw-text-gray-400 mb-2 {% if not forloop.first %}mt-3{% endif %}">{{ p.currency.name }} ({{ p.currency.code }})</p>
<p class="tw-text-gray-400 mb-2 {% if not forloop.first %}mt-3{% endif %}">{{ p.currency.name }}</p>
<c-ui.percentage-distribution :percentage="p"></c-ui.percentage-distribution>
{% endfor %}
</c-ui.info-card>
</div>
{% endif %}
</div>

View File

@@ -13,89 +13,186 @@
{% 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>
<div id="search" class="my-3">
<label class="w-100">
<input type="search" class="form-control" placeholder="Buscar" hx-preserve id="quick-search"
_="on input or search or htmx:afterSwap from window
if my value is empty
trigger toggle on <.transactions-divider-collapse/>
else
trigger show on <.transactions-divider-collapse/>
end
show <.transactions-divider-title/> when my value is empty
show <.transaction/> in <#transactions-list/>
when its textContent.toLowerCase() contains my value.toLowerCase()">
</label>
</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,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add action to transaction rule' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule_id %}" 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 transaction rule action' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -5,80 +5,121 @@
{% block title %}{% translate 'Transaction Rule' %}{% endblock %}
{% block body %}
<div hx-get="{% url 'transaction_rule_view' transaction_rule_id=transaction_rule.id %}" hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading">
<div class="tw-text-2xl">{{ transaction_rule.name }}</div>
<div class="tw-text-base tw-text-gray-400">{{ transaction_rule.description }}</div>
<hr>
<div class="my-3">
<div class="tw-text-xl">{% translate 'If transaction...' %}</div>
<div class="card">
<div class="card-body">
{{ transaction_rule.trigger }}
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_edit' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">
<div hx-get="{% url 'transaction_rule_view' transaction_rule_id=transaction_rule.id %}"
hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading">
<div class="tw-text-2xl">{{ transaction_rule.name }}</div>
<div class="tw-text-base tw-text-gray-400">{{ transaction_rule.description }}</div>
<hr>
<div class="my-3">
<div class="tw-text-xl mb-2">{% translate 'If transaction...' %}</div>
<div class="card">
<div class="card-body">
{{ transaction_rule.trigger }}
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_edit' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="my-3">
<div class="tw-text-xl">{% translate 'Then...' %}</div>
{% for action in transaction_rule.transaction_actions.all %}
<div class="card mb-3">
<div class="card-body">
<div class="card mb-3">
<div class="card-header">{% translate 'Set' %}</div>
<div class="card-body">{{ action.get_field_display }}</div>
<div class="my-3">
<div class="tw-text-xl mb-2">{% translate 'Then...' %}</div>
{% for action in transaction_rule.transaction_actions.all %}
<div class="card mb-3">
<div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div>
</div>
<div class="card-body">
<div>{% translate 'Set' %} <span
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div>
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.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>
</div>
{% endfor %}
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
<div class="card mb-3">
<div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div>
</div>
<div class="card-body">
<div>{% trans 'Edit to view' %}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.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>
</div>
{% endfor %}
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %}
<div class="card">
<div class="card-body">
{% translate 'This rule has no actions' %}
</div>
</div>
{% endif %}
<hr>
<div class="dropdown">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</button>
<ul class="dropdown-menu dropdown-menu-end w-100">
<li><a class="dropdown-item" role="link"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
<li><a class="dropdown-item" role="link"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
</ul>
</div>
<div class="card mb-3">
<div class="card-header">{% translate 'to' %}</div>
<div class="card-body">{{ action.value }}</div>
</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.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>
</div>
{% empty %}
<div class="card">
<div class="card-body">
{% translate 'This rule has no actions' %}
</div>
</div>
{% endfor %}
<hr>
<a class="btn btn-outline-primary text-decoration-none w-100"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
role="button"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</a>
</div>
</div>
{% endblock %}

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
# 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 watchfiles --filter python "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;

View File

@@ -27,3 +27,5 @@ simpleeval~=1.0.0
pydantic~=2.10.5
PyYAML~=6.0.2
mistune~=3.1.1
openpyxl~=3.1
xlrd~=2.0