mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cb0a2a0f | ||
|
|
05ede58c36 | ||
|
|
20b6366a18 | ||
|
|
b0101dae1a | ||
|
|
a3d38ff9e0 | ||
|
|
776e2117a0 | ||
|
|
edcad37926 | ||
|
|
2d51d21035 | ||
|
|
94f5c25829 | ||
|
|
88a5c103e5 | ||
|
|
3dce9e1c55 | ||
|
|
41d8564e8b | ||
|
|
5ee2fd244f | ||
|
|
0545fb7651 | ||
|
|
7bd1d2d751 | ||
|
|
9a4ec449df | ||
|
|
f918351303 | ||
|
|
ef66b3a1e5 | ||
|
|
7486660223 | ||
|
|
00d5ccda34 | ||
|
|
1656eec601 | ||
|
|
64b96ed2f3 | ||
|
|
1f5e4f132d | ||
|
|
edf056b68c | ||
|
|
35865ce21c | ||
|
|
8f06c06d32 | ||
|
|
15eaa2239a | ||
|
|
fd7214df95 | ||
|
|
e531c63de3 | ||
|
|
5a79dd5424 | ||
|
|
315dd1479a | ||
|
|
67f79effab | ||
|
|
c168886968 | ||
|
|
272c34d3b3 | ||
|
|
43ce79ae65 | ||
|
|
4aa29545ec | ||
|
|
fd1fcb832c | ||
|
|
b5fd928a5d | ||
|
|
2dc398f82b | ||
|
|
cf7d4b1404 | ||
|
|
e9c3af1a85 | ||
|
|
b121e8e982 | ||
|
|
606e6b3843 | ||
|
|
6e46b5abb8 | ||
|
|
5b4dab93a1 | ||
|
|
29b6ee3af3 | ||
|
|
484686b709 | ||
|
|
938c128d07 | ||
|
|
8123f7f3cb | ||
|
|
547dc90d9e | ||
|
|
dc33fda5d3 | ||
|
|
92960d1b9a | ||
|
|
1978a467cb | ||
|
|
5bdafbba91 | ||
|
|
16de87376a | ||
|
|
e8e1144fdd | ||
|
|
157f357a7a | ||
|
|
d77eddbd26 | ||
|
|
fb1b383962 | ||
|
|
11998475c5 | ||
|
|
21363e23a1 | ||
|
|
d3a816d91b | ||
|
|
9c92bbd3cf | ||
|
|
c55d688956 | ||
|
|
231b9065c9 | ||
|
|
01ea0de4b3 | ||
|
|
c57fa1630b | ||
|
|
92f7bcfd9e | ||
|
|
58b855f55e | ||
|
|
d4d51301b3 | ||
|
|
aed3fb11fe | ||
|
|
70d427bec4 | ||
|
|
b6f52458db | ||
|
|
8d76c40b7e | ||
|
|
a43e3d158f | ||
|
|
588ae2de6e | ||
|
|
4b97ba681a | ||
|
|
1a903507ad | ||
|
|
bf920df771 | ||
|
|
23ae6f3d54 | ||
|
|
49f28834e9 | ||
|
|
4351027b87 |
@@ -127,7 +127,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
|
||||
| variable | type | default | explanation |
|
||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
|
||||
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
|
||||
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
|
||||
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
|
||||
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
|
||||
@@ -145,7 +145,10 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
| DEMO | true\|false | false | If demo mode is enabled. |
|
||||
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
|
||||
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
|
||||
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
|
||||
| CHECK_FOR_UPDATES | true\|false | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
|
||||
| DJANGO_VITE_DEV_MODE | true\|false | false | Enables Vite dev server mode for frontend development. When true, assets are served from Vite's dev server instead of the build manifest. For development only! |
|
||||
| DJANGO_VITE_DEV_SERVER_PORT | int | 5173 | The port where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
|
||||
| DJANGO_VITE_DEV_SERVER_HOST | string | localhost | The host where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
THREADS = int(os.getenv("GUNICORN_THREADS", 1))
|
||||
MAX_POOL_SIZE = THREADS + 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
@@ -151,6 +154,17 @@ DATABASES = {
|
||||
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
|
||||
"HOST": os.getenv("SQL_HOST", "localhost"),
|
||||
"PORT": os.getenv("SQL_PORT", "5432"),
|
||||
"CONN_MAX_AGE": 0,
|
||||
"CONN_HEALTH_CHECKS": True,
|
||||
"OPTIONS": {
|
||||
"pool": {
|
||||
"min_size": 1,
|
||||
"max_size": MAX_POOL_SIZE,
|
||||
"timeout": 10,
|
||||
"max_lifetime": 600,
|
||||
"max_idle": 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,9 +330,9 @@ CACHES = {
|
||||
|
||||
DJANGO_VITE_ASSETS_PATH = STATIC_ROOT
|
||||
DJANGO_VITE_MANIFEST_PATH = DJANGO_VITE_ASSETS_PATH / "manifest.json"
|
||||
DJANGO_VITE_DEV_MODE = DEBUG
|
||||
DJANGO_VITE_DEV_SERVER_PORT = 5173
|
||||
DJANGO_VITE_DEV_SERVER_HOST = "localhost"
|
||||
DJANGO_VITE_DEV_MODE = os.getenv("DJANGO_VITE_DEV_MODE", "false").lower() == "true"
|
||||
DJANGO_VITE_DEV_SERVER_PORT = int(os.getenv("DJANGO_VITE_DEV_SERVER_PORT", "5173"))
|
||||
DJANGO_VITE_DEV_SERVER_HOST = os.getenv("DJANGO_VITE_DEV_SERVER_HOST", "localhost")
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
33
app/apps/accounts/services.py
Normal file
33
app/apps/accounts/services.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
def get_account_balance(account: Account, paid_only: bool = True) -> Decimal:
|
||||
"""
|
||||
Calculate account balance (income - expense).
|
||||
|
||||
Args:
|
||||
account: Account instance to calculate balance for.
|
||||
paid_only: If True, only count paid transactions (current balance).
|
||||
If False, count all transactions (projected balance).
|
||||
|
||||
Returns:
|
||||
Decimal: The calculated balance (income - expense).
|
||||
"""
|
||||
filters = {"account": account}
|
||||
if paid_only:
|
||||
filters["is_paid"] = True
|
||||
|
||||
income = Transaction.objects.filter(
|
||||
type=Transaction.Type.INCOME, **filters
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
expense = Transaction.objects.filter(
|
||||
type=Transaction.Type.EXPENSE, **filters
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
return income - expense
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import date
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
@@ -39,3 +41,135 @@ class AccountTests(TestCase):
|
||||
exchange_currency=self.exchange_currency,
|
||||
)
|
||||
self.assertEqual(account.exchange_currency, self.exchange_currency)
|
||||
|
||||
|
||||
class GetAccountBalanceServiceTests(TestCase):
|
||||
"""Tests for the get_account_balance service function"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
from apps.transactions.models import Transaction
|
||||
self.Transaction = Transaction
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="BRL", name="Brazilian Real", decimal_places=2, prefix="R$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Service Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Service Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
def test_balance_with_no_transactions(self):
|
||||
"""Test balance is 0 when no transactions exist"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
balance = get_account_balance(self.account, paid_only=True)
|
||||
self.assertEqual(balance, Decimal("0"))
|
||||
|
||||
def test_current_balance_only_counts_paid(self):
|
||||
"""Test current balance only counts paid transactions"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
# Paid income
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid income",
|
||||
)
|
||||
# Unpaid income (should not count)
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid income",
|
||||
)
|
||||
# Paid expense
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.EXPENSE,
|
||||
amount=Decimal("30.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid expense",
|
||||
)
|
||||
|
||||
balance = get_account_balance(self.account, paid_only=True)
|
||||
self.assertEqual(balance, Decimal("70.00")) # 100 - 30
|
||||
|
||||
def test_projected_balance_counts_all(self):
|
||||
"""Test projected balance counts all transactions"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
# Paid income
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid income",
|
||||
)
|
||||
# Unpaid income
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid income",
|
||||
)
|
||||
# Paid expense
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.EXPENSE,
|
||||
amount=Decimal("30.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid expense",
|
||||
)
|
||||
# Unpaid expense
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.EXPENSE,
|
||||
amount=Decimal("20.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid expense",
|
||||
)
|
||||
|
||||
balance = get_account_balance(self.account, paid_only=False)
|
||||
self.assertEqual(balance, Decimal("100.00")) # (100 + 50) - (30 + 20)
|
||||
|
||||
def test_balance_defaults_to_paid_only(self):
|
||||
"""Test that paid_only defaults to True"""
|
||||
from apps.accounts.services import get_account_balance
|
||||
from decimal import Decimal
|
||||
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid",
|
||||
)
|
||||
self.Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=self.Transaction.Type.INCOME,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 1),
|
||||
description="Unpaid",
|
||||
)
|
||||
|
||||
balance = get_account_balance(self.account) # defaults to paid_only=True
|
||||
self.assertEqual(balance, Decimal("100.00"))
|
||||
|
||||
|
||||
@@ -11,23 +11,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.forms import AccountBalanceFormSet
|
||||
from apps.accounts.models import Account, Transaction
|
||||
from apps.accounts.services import get_account_balance
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def account_reconciliation(request):
|
||||
def get_account_balance(account):
|
||||
income = Transaction.objects.filter(
|
||||
account=account, type=Transaction.Type.INCOME, is_paid=True
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
expense = Transaction.objects.filter(
|
||||
account=account, type=Transaction.Type.EXPENSE, is_paid=True
|
||||
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
return income - expense
|
||||
|
||||
initial_data = [
|
||||
{
|
||||
"account_id": account.id,
|
||||
|
||||
@@ -10,15 +10,19 @@ from apps.transactions.models import (
|
||||
|
||||
@extend_schema_field(
|
||||
{
|
||||
"oneOf": [{"type": "string"}, {"type": "integer"}],
|
||||
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created",
|
||||
"oneOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}],
|
||||
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created. Can be null if no category is assigned.",
|
||||
}
|
||||
)
|
||||
class TransactionCategoryField(serializers.Field):
|
||||
def to_representation(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return {"id": value.id, "name": value.name}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return None
|
||||
if isinstance(data, int):
|
||||
try:
|
||||
return TransactionCategory.objects.get(pk=data)
|
||||
|
||||
@@ -2,3 +2,5 @@ from .transactions import *
|
||||
from .accounts import *
|
||||
from .currencies import *
|
||||
from .dca import *
|
||||
from .imports import *
|
||||
|
||||
|
||||
@@ -67,3 +67,12 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
class AccountBalanceSerializer(serializers.Serializer):
|
||||
"""Serializer for account balance response."""
|
||||
|
||||
current_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
|
||||
projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
|
||||
currency = CurrencySerializer()
|
||||
|
||||
|
||||
41
app/apps/api/serializers/imports.py
Normal file
41
app/apps/api/serializers/imports.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
|
||||
class ImportProfileSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for listing import profiles."""
|
||||
|
||||
class Meta:
|
||||
model = ImportProfile
|
||||
fields = ["id", "name", "version", "yaml_config"]
|
||||
|
||||
|
||||
class ImportRunSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for listing import runs."""
|
||||
|
||||
class Meta:
|
||||
model = ImportRun
|
||||
fields = [
|
||||
"id",
|
||||
"status",
|
||||
"profile",
|
||||
"file_name",
|
||||
"logs",
|
||||
"processed_rows",
|
||||
"total_rows",
|
||||
"successful_rows",
|
||||
"skipped_rows",
|
||||
"failed_rows",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
]
|
||||
|
||||
|
||||
class ImportFileSerializer(serializers.Serializer):
|
||||
"""Serializer for uploading a file to import using an existing profile."""
|
||||
|
||||
profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ImportProfile.objects.all(), source="profile"
|
||||
)
|
||||
file = serializers.FileField()
|
||||
4
app/apps/api/tests/__init__.py
Normal file
4
app/apps/api/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Import all test classes for Django test discovery
|
||||
from .test_imports import *
|
||||
from .test_accounts import *
|
||||
|
||||
99
app/apps/api/tests/test_accounts.py
Normal file
99
app/apps/api/tests/test_accounts.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class AccountBalanceAPITests(TestCase):
|
||||
"""Tests for the Account Balance API endpoint"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
# Create some transactions
|
||||
Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("500.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Paid income",
|
||||
)
|
||||
Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("200.00"),
|
||||
is_paid=False,
|
||||
date=date(2025, 1, 15),
|
||||
description="Unpaid income",
|
||||
)
|
||||
Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 10),
|
||||
description="Paid expense",
|
||||
)
|
||||
|
||||
def test_get_balance_success(self):
|
||||
"""Test successful balance retrieval"""
|
||||
response = self.client.get(f"/api/accounts/{self.account.id}/balance/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("current_balance", response.data)
|
||||
self.assertIn("projected_balance", response.data)
|
||||
self.assertIn("currency", response.data)
|
||||
|
||||
# Current: 500 - 100 = 400
|
||||
self.assertEqual(Decimal(response.data["current_balance"]), Decimal("400.00"))
|
||||
# Projected: (500 + 200) - 100 = 600
|
||||
self.assertEqual(Decimal(response.data["projected_balance"]), Decimal("600.00"))
|
||||
|
||||
# Check currency data
|
||||
self.assertEqual(response.data["currency"]["code"], "USD")
|
||||
|
||||
def test_get_balance_nonexistent_account(self):
|
||||
"""Test balance for non-existent account returns 404"""
|
||||
response = self.client.get("/api/accounts/99999/balance/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_get_balance_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get(
|
||||
f"/api/accounts/{self.account.id}/balance/"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
404
app/apps/api/tests/test_imports.py
Normal file
404
app/apps/api/tests/test_imports.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ImportAPITests(TestCase):
|
||||
"""Tests for the Import API endpoint"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create a basic import profile with minimal valid YAML config
|
||||
self.profile = ImportProfile.objects.create(
|
||||
name="Test Profile",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_expense
|
||||
is_paid:
|
||||
detection_method: always_paid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
|
||||
@patch("apps.import_app.tasks.process_import.defer")
|
||||
@patch("django.core.files.storage.FileSystemStorage.save")
|
||||
@patch("django.core.files.storage.FileSystemStorage.path")
|
||||
def test_create_import_success(self, mock_path, mock_save, mock_defer):
|
||||
"""Test successful file upload creates ImportRun and queues task"""
|
||||
mock_save.return_value = "test_file.csv"
|
||||
mock_path.return_value = "/usr/src/app/temp/test_file.csv"
|
||||
|
||||
csv_content = b"date,description,amount,account\n2025-01-01,Test,100,Main"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertIn("import_run_id", response.data)
|
||||
self.assertEqual(response.data["status"], "queued")
|
||||
|
||||
# Verify ImportRun was created
|
||||
import_run = ImportRun.objects.get(id=response.data["import_run_id"])
|
||||
self.assertEqual(import_run.profile, self.profile)
|
||||
self.assertEqual(import_run.file_name, "test_file.csv")
|
||||
|
||||
# Verify task was deferred
|
||||
mock_defer.assert_called_once_with(
|
||||
import_run_id=import_run.id,
|
||||
file_path="/usr/src/app/temp/test_file.csv",
|
||||
user_id=self.user.id,
|
||||
)
|
||||
|
||||
def test_create_import_missing_profile(self):
|
||||
"""Test request without profile_id returns 400"""
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("profile_id", response.data)
|
||||
|
||||
def test_create_import_missing_file(self):
|
||||
"""Test request without file returns 400"""
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("file", response.data)
|
||||
|
||||
def test_create_import_invalid_profile(self):
|
||||
"""Test request with non-existent profile returns 400"""
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": 99999, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("profile_id", response.data)
|
||||
|
||||
@patch("apps.import_app.tasks.process_import.defer")
|
||||
@patch("django.core.files.storage.FileSystemStorage.save")
|
||||
@patch("django.core.files.storage.FileSystemStorage.path")
|
||||
def test_create_import_xlsx(self, mock_path, mock_save, mock_defer):
|
||||
"""Test successful XLSX file upload"""
|
||||
mock_save.return_value = "test_file.xlsx"
|
||||
mock_path.return_value = "/usr/src/app/temp/test_file.xlsx"
|
||||
|
||||
# Create a simple XLSX-like content (just for the upload test)
|
||||
xlsx_content = BytesIO(b"PK\x03\x04") # XLSX files start with PK header
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.xlsx",
|
||||
xlsx_content.getvalue(),
|
||||
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertIn("import_run_id", response.data)
|
||||
|
||||
def test_unauthenticated_request(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
file = SimpleUploadedFile(
|
||||
"test_file.csv", csv_content, content_type="text/csv"
|
||||
)
|
||||
|
||||
response = unauthenticated_client.post(
|
||||
"/api/import/import/",
|
||||
{"profile_id": self.profile.id, "file": file},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ImportProfileAPITests(TestCase):
|
||||
"""Tests for the Import Profile API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.profile1 = ImportProfile.objects.create(
|
||||
name="Profile 1",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_expense
|
||||
is_paid:
|
||||
detection_method: always_paid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
self.profile2 = ImportProfile.objects.create(
|
||||
name="Profile 2",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_income
|
||||
is_paid:
|
||||
detection_method: always_unpaid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
|
||||
def test_list_profiles(self):
|
||||
"""Test listing all profiles"""
|
||||
response = self.client.get("/api/import/profiles/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
self.assertEqual(len(response.data["results"]), 2)
|
||||
|
||||
def test_retrieve_profile(self):
|
||||
"""Test retrieving a specific profile"""
|
||||
response = self.client.get(f"/api/import/profiles/{self.profile1.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["id"], self.profile1.id)
|
||||
self.assertEqual(response.data["name"], "Profile 1")
|
||||
self.assertIn("yaml_config", response.data)
|
||||
|
||||
def test_retrieve_nonexistent_profile(self):
|
||||
"""Test retrieving a non-existent profile returns 404"""
|
||||
response = self.client.get("/api/import/profiles/99999/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_profiles_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get("/api/import/profiles/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ImportRunAPITests(TestCase):
|
||||
"""Tests for the Import Run API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.profile1 = ImportProfile.objects.create(
|
||||
name="Profile 1",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_expense
|
||||
is_paid:
|
||||
detection_method: always_paid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
self.profile2 = ImportProfile.objects.create(
|
||||
name="Profile 2",
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
yaml_config="""
|
||||
file_type: csv
|
||||
date_format: "%Y-%m-%d"
|
||||
column_mapping:
|
||||
date:
|
||||
source: date
|
||||
description:
|
||||
source: description
|
||||
amount:
|
||||
source: amount
|
||||
transaction_type:
|
||||
detection_method: always_income
|
||||
is_paid:
|
||||
detection_method: always_unpaid
|
||||
account:
|
||||
source: account
|
||||
match_field: name
|
||||
""",
|
||||
)
|
||||
|
||||
# Create import runs
|
||||
self.run1 = ImportRun.objects.create(
|
||||
profile=self.profile1,
|
||||
file_name="file1.csv",
|
||||
status=ImportRun.Status.FINISHED,
|
||||
)
|
||||
self.run2 = ImportRun.objects.create(
|
||||
profile=self.profile1,
|
||||
file_name="file2.csv",
|
||||
status=ImportRun.Status.QUEUED,
|
||||
)
|
||||
self.run3 = ImportRun.objects.create(
|
||||
profile=self.profile2,
|
||||
file_name="file3.csv",
|
||||
status=ImportRun.Status.FINISHED,
|
||||
)
|
||||
|
||||
def test_list_all_runs(self):
|
||||
"""Test listing all runs"""
|
||||
response = self.client.get("/api/import/runs/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 3)
|
||||
self.assertEqual(len(response.data["results"]), 3)
|
||||
|
||||
def test_list_runs_by_profile(self):
|
||||
"""Test filtering runs by profile_id"""
|
||||
response = self.client.get(f"/api/import/runs/?profile_id={self.profile1.id}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
for run in response.data["results"]:
|
||||
self.assertEqual(run["profile"], self.profile1.id)
|
||||
|
||||
def test_list_runs_by_other_profile(self):
|
||||
"""Test filtering runs by another profile_id"""
|
||||
response = self.client.get(f"/api/import/runs/?profile_id={self.profile2.id}")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["results"][0]["profile"], self.profile2.id)
|
||||
|
||||
def test_retrieve_run(self):
|
||||
"""Test retrieving a specific run"""
|
||||
response = self.client.get(f"/api/import/runs/{self.run1.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["id"], self.run1.id)
|
||||
self.assertEqual(response.data["file_name"], "file1.csv")
|
||||
self.assertEqual(response.data["status"], "FINISHED")
|
||||
|
||||
def test_retrieve_nonexistent_run(self):
|
||||
"""Test retrieving a non-existent run returns 404"""
|
||||
response = self.client.get("/api/import/runs/99999/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_runs_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get("/api/import/runs/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
@@ -16,7 +16,11 @@ router.register(r"currencies", views.CurrencyViewSet)
|
||||
router.register(r"exchange-rates", views.ExchangeRateViewSet)
|
||||
router.register(r"dca/strategies", views.DCAStrategyViewSet)
|
||||
router.register(r"dca/entries", views.DCAEntryViewSet)
|
||||
router.register(r"import/profiles", views.ImportProfileViewSet, basename="import-profiles")
|
||||
router.register(r"import/runs", views.ImportRunViewSet, basename="import-runs")
|
||||
router.register(r"import/import", views.ImportViewSet, basename="import-import")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@ from .transactions import *
|
||||
from .accounts import *
|
||||
from .currencies import *
|
||||
from .dca import *
|
||||
from .imports import *
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
from apps.accounts.models import AccountGroup, Account
|
||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
|
||||
from apps.accounts.services import get_account_balance
|
||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer
|
||||
|
||||
|
||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing account groups."""
|
||||
|
||||
queryset = AccountGroup.objects.all()
|
||||
serializer_class = AccountGroupSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
@@ -14,7 +21,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
return AccountGroup.objects.all().order_by("id")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
balance=extend_schema(
|
||||
summary="Get account balance",
|
||||
description="Returns the current and projected balance for the account, along with currency data.",
|
||||
responses={200: AccountBalanceSerializer},
|
||||
),
|
||||
)
|
||||
class AccountViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing accounts."""
|
||||
|
||||
queryset = Account.objects.all()
|
||||
serializer_class = AccountSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
@@ -25,3 +41,20 @@ class AccountViewSet(viewsets.ModelViewSet):
|
||||
.order_by("id")
|
||||
.select_related("group", "currency", "exchange_currency")
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
||||
def balance(self, request, pk=None):
|
||||
"""Get current and projected balance for an account."""
|
||||
account = self.get_object()
|
||||
|
||||
current_balance = get_account_balance(account, paid_only=True)
|
||||
projected_balance = get_account_balance(account, paid_only=False)
|
||||
|
||||
serializer = AccountBalanceSerializer({
|
||||
"current_balance": current_balance,
|
||||
"projected_balance": projected_balance,
|
||||
"currency": account.currency,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
123
app/apps/api/views/imports.py
Normal file
123
app/apps/api/views/imports.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer
|
||||
from rest_framework import serializers as drf_serializers
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.api.serializers import ImportFileSerializer, ImportProfileSerializer, ImportRunSerializer
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.tasks import process_import
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List import profiles",
|
||||
description="Returns a paginated list of all available import profiles.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get import profile",
|
||||
description="Returns the details of a specific import profile by ID.",
|
||||
),
|
||||
)
|
||||
class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for listing and retrieving import profiles."""
|
||||
|
||||
queryset = ImportProfile.objects.all()
|
||||
serializer_class = ImportProfileSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List import runs",
|
||||
description="Returns a paginated list of import runs. Optionally filter by profile_id.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="profile_id",
|
||||
type=int,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter runs by profile ID",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get import run",
|
||||
description="Returns the details of a specific import run by ID, including status and logs.",
|
||||
),
|
||||
)
|
||||
class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for listing and retrieving import runs."""
|
||||
|
||||
queryset = ImportRun.objects.all().order_by("-id")
|
||||
serializer_class = ImportRunSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
profile_id = self.request.query_params.get("profile_id")
|
||||
if profile_id:
|
||||
queryset = queryset.filter(profile_id=profile_id)
|
||||
return queryset
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
create=extend_schema(
|
||||
summary="Import file",
|
||||
description="Upload a CSV or XLSX file to import using an existing import profile. The import is queued and processed asynchronously.",
|
||||
request={
|
||||
"multipart/form-data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {"type": "integer", "description": "ID of the ImportProfile to use"},
|
||||
"file": {"type": "string", "format": "binary", "description": "CSV or XLSX file to import"},
|
||||
},
|
||||
"required": ["profile_id", "file"],
|
||||
},
|
||||
},
|
||||
responses={
|
||||
202: inline_serializer(
|
||||
name="ImportResponse",
|
||||
fields={
|
||||
"import_run_id": drf_serializers.IntegerField(),
|
||||
"status": drf_serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class ImportViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for importing data via file upload."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def create(self, request):
|
||||
serializer = ImportFileSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
profile = serializer.validated_data["profile"]
|
||||
uploaded_file = serializer.validated_data["file"]
|
||||
|
||||
# Save file to temp location
|
||||
fs = FileSystemStorage(location="/usr/src/app/temp")
|
||||
filename = fs.save(uploaded_file.name, uploaded_file)
|
||||
file_path = fs.path(filename)
|
||||
|
||||
# Create ImportRun record
|
||||
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
|
||||
|
||||
# Queue import task
|
||||
process_import.defer(
|
||||
import_run_id=import_run.id,
|
||||
file_path=file_path,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"import_run_id": import_run.id, "status": "queued"},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
||||
transaction_created.send(sender=instance)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||
old_data = deepcopy(self.get_object())
|
||||
instance = serializer.save()
|
||||
transaction_updated.send(sender=instance, old_data=old_data)
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 4 * * *")
|
||||
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
|
||||
@app.task(
|
||||
lock="remove_old_jobs",
|
||||
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(
|
||||
@@ -36,7 +41,11 @@ async def remove_old_jobs(context, timestamp):
|
||||
|
||||
|
||||
@app.periodic(cron="0 6 1 * *")
|
||||
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
|
||||
@app.task(
|
||||
lock="remove_expired_sessions",
|
||||
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:
|
||||
@@ -49,7 +58,7 @@ async def remove_expired_sessions(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="0 8 * * *")
|
||||
@app.task(name="reset_demo_data")
|
||||
@app.task(lock="reset_demo_data", name="reset_demo_data")
|
||||
def reset_demo_data(timestamp=None):
|
||||
"""
|
||||
Wipes the database and loads fresh demo data if DEMO mode is active.
|
||||
@@ -86,9 +95,7 @@ def reset_demo_data(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="0 */12 * * *") # Every 12 hours
|
||||
@app.task(
|
||||
name="check_for_updates",
|
||||
)
|
||||
@app.task(lock="check_for_updates", name="check_for_updates")
|
||||
def check_for_updates(timestamp=None):
|
||||
if not settings.CHECK_FOR_UPDATES:
|
||||
return "CHECK_FOR_UPDATES is disabled"
|
||||
|
||||
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 * * * *") # Run every hour
|
||||
@app.task(name="automatic_fetch_exchange_rates")
|
||||
@app.task(lock="automatic_fetch_exchange_rates", name="automatic_fetch_exchange_rates")
|
||||
def automatic_fetch_exchange_rates(timestamp=None):
|
||||
"""Fetch exchange rates for all due services"""
|
||||
fetcher = ExchangeRateFetcher()
|
||||
@@ -19,7 +19,7 @@ def automatic_fetch_exchange_rates(timestamp=None):
|
||||
logger.error(e, exc_info=True)
|
||||
|
||||
|
||||
@app.task(name="manual_fetch_exchange_rates")
|
||||
@app.task(lock="manual_fetch_exchange_rates", name="manual_fetch_exchange_rates")
|
||||
def manual_fetch_exchange_rates(timestamp=None):
|
||||
"""Fetch exchange rates for all due services"""
|
||||
fetcher = ExchangeRateFetcher()
|
||||
|
||||
@@ -459,12 +459,13 @@ class ImportService:
|
||||
# Build query conditions for each field in the rule
|
||||
for field in rule.fields:
|
||||
if field in transaction_data:
|
||||
if rule.match_type == "strict":
|
||||
query = query.filter(**{field: transaction_data[field]})
|
||||
else: # lax matching
|
||||
query = query.filter(
|
||||
**{f"{field}__iexact": transaction_data[field]}
|
||||
)
|
||||
value = transaction_data[field]
|
||||
# Use __iexact only for string fields; non-string types
|
||||
# (date, Decimal, bool, int, etc.) don't support UPPER()
|
||||
if rule.match_type == "strict" or not isinstance(value, str):
|
||||
query = query.filter(**{field: value})
|
||||
else: # lax matching for strings only
|
||||
query = query.filter(**{f"{field}__iexact": value})
|
||||
|
||||
# If we found any matching transaction, it's a duplicate
|
||||
if query.exists():
|
||||
@@ -475,11 +476,27 @@ class ImportService:
|
||||
def _coerce_type(
|
||||
self, value: str, mapping: version_1.ColumnMapping
|
||||
) -> Union[str, int, bool, Decimal, datetime, list, None]:
|
||||
coerce_to = mapping.coerce_to
|
||||
|
||||
# Handle detection methods that don't require a source value
|
||||
if coerce_to == "transaction_type" and isinstance(
|
||||
mapping, version_1.TransactionTypeMapping
|
||||
):
|
||||
if mapping.detection_method == "always_income":
|
||||
return Transaction.Type.INCOME
|
||||
elif mapping.detection_method == "always_expense":
|
||||
return Transaction.Type.EXPENSE
|
||||
elif coerce_to == "is_paid" and isinstance(
|
||||
mapping, version_1.TransactionIsPaidMapping
|
||||
):
|
||||
if mapping.detection_method == "always_paid":
|
||||
return True
|
||||
elif mapping.detection_method == "always_unpaid":
|
||||
return False
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
coerce_to = mapping.coerce_to
|
||||
|
||||
return self._coerce_single_type(value, coerce_to, mapping)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
0
app/apps/import_app/tests/__init__.py
Normal file
0
app/apps/import_app/tests/__init__.py
Normal file
276
app/apps/import_app/tests/test_import_service_v1.py
Normal file
276
app/apps/import_app/tests/test_import_service_v1.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Tests for ImportService v1, specifically for deduplication logic.
|
||||
|
||||
These tests verify that the _check_duplicate_transaction method handles
|
||||
different field types correctly, particularly ensuring that __iexact
|
||||
is only used for string fields (not dates, decimals, etc.).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
class DeduplicationTests(TestCase):
|
||||
"""Tests for transaction deduplication during import."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
|
||||
# Create an existing transaction for deduplication tests
|
||||
self.existing_transaction = Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
date=date(2024, 1, 15),
|
||||
amount=Decimal("100.00"),
|
||||
description="Existing Transaction",
|
||||
internal_id="ABC123",
|
||||
)
|
||||
|
||||
def _create_import_service_with_deduplication(
|
||||
self, fields: list[str], match_type: str = "lax"
|
||||
) -> ImportService:
|
||||
"""Helper to create an ImportService with specific deduplication rules."""
|
||||
yaml_config = f"""
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
trigger_transaction_rules: false
|
||||
mapping:
|
||||
date_field:
|
||||
source: date
|
||||
target: date
|
||||
format: "%Y-%m-%d"
|
||||
amount_field:
|
||||
source: amount
|
||||
target: amount
|
||||
description_field:
|
||||
source: description
|
||||
target: description
|
||||
account_field:
|
||||
source: account
|
||||
target: account
|
||||
type: id
|
||||
deduplication:
|
||||
- type: compare
|
||||
fields: {fields}
|
||||
match_type: {match_type}
|
||||
"""
|
||||
profile = ImportProfile.objects.create(
|
||||
name=f"Test Profile {match_type} {'_'.join(fields)}",
|
||||
yaml_config=yaml_config,
|
||||
version=ImportProfile.Versions.VERSION_1,
|
||||
)
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile,
|
||||
file_name="test.csv",
|
||||
)
|
||||
return ImportService(import_run)
|
||||
|
||||
def test_deduplication_with_date_field_strict_match(self):
|
||||
"""Test that date fields work with strict matching."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date"], match_type="strict"
|
||||
)
|
||||
|
||||
# Should find duplicate when date matches
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_date_field_lax_match(self):
|
||||
"""
|
||||
Test that date fields use strict matching even when match_type is 'lax'.
|
||||
|
||||
This is the fix for the UPPER(date) PostgreSQL error. Date fields
|
||||
cannot use __iexact, so they should fall back to strict matching.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when date matches (using strict comparison)
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_amount_field_lax_match(self):
|
||||
"""
|
||||
Test that Decimal fields use strict matching even when match_type is 'lax'.
|
||||
|
||||
Decimal fields cannot use __iexact, so they should fall back to strict matching.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["amount"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when amount matches
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"amount": Decimal("100.00")}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when amount differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"amount": Decimal("200.00")}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_string_field_lax_match(self):
|
||||
"""
|
||||
Test that string fields use case-insensitive matching with match_type 'lax'.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["description"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate with case-insensitive match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "EXISTING TRANSACTION"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact case match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Existing Transaction"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when description differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Different Transaction"}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_string_field_strict_match(self):
|
||||
"""
|
||||
Test that string fields use case-sensitive matching with match_type 'strict'.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["description"], match_type="strict"
|
||||
)
|
||||
|
||||
# Should NOT find duplicate with different case (strict matching)
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "EXISTING TRANSACTION"}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact case match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"description": "Existing Transaction"}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
def test_deduplication_with_multiple_fields_mixed_types(self):
|
||||
"""
|
||||
Test deduplication with multiple fields of different types.
|
||||
|
||||
Verifies that string fields use __iexact while non-string fields
|
||||
use strict matching, all in the same deduplication rule.
|
||||
"""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "amount", "description"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate when all fields match (with case-insensitive description)
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("100.00"),
|
||||
"description": "existing transaction", # lowercase should match
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should NOT find duplicate when date differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 2, 20),
|
||||
"amount": Decimal("100.00"),
|
||||
"description": "existing transaction",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
# Should NOT find duplicate when amount differs
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("999.99"),
|
||||
"description": "existing transaction",
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_internal_id_lax_match(self):
|
||||
"""Test deduplication with internal_id field using lax matching."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["internal_id"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should find duplicate with case-insensitive match
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{"internal_id": "abc123"} # lowercase should match ABC123
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should find duplicate with exact match
|
||||
is_duplicate = service._check_duplicate_transaction({"internal_id": "ABC123"})
|
||||
self.assertTrue(is_duplicate)
|
||||
|
||||
# Should not find duplicate when internal_id differs
|
||||
is_duplicate = service._check_duplicate_transaction({"internal_id": "XYZ789"})
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_no_duplicate_when_no_transactions_exist(self):
|
||||
"""Test that no duplicate is found when there are no matching transactions."""
|
||||
# Hard delete to bypass signals that require user context
|
||||
self.existing_transaction.hard_delete()
|
||||
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "amount"], match_type="lax"
|
||||
)
|
||||
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
"amount": Decimal("100.00"),
|
||||
}
|
||||
)
|
||||
self.assertFalse(is_duplicate)
|
||||
|
||||
def test_deduplication_with_missing_field_in_data(self):
|
||||
"""Test that missing fields in transaction_data are handled gracefully."""
|
||||
service = self._create_import_service_with_deduplication(
|
||||
fields=["date", "nonexistent_field"], match_type="lax"
|
||||
)
|
||||
|
||||
# Should still work, only checking the fields that exist
|
||||
is_duplicate = service._check_duplicate_transaction(
|
||||
{
|
||||
"date": date(2024, 1, 15),
|
||||
}
|
||||
)
|
||||
self.assertTrue(is_duplicate)
|
||||
@@ -74,7 +74,9 @@ def index(request):
|
||||
def sankey_by_account(request):
|
||||
# Get filtered transactions
|
||||
|
||||
transactions = get_transactions(request, include_untracked_accounts=True)
|
||||
transactions = get_transactions(
|
||||
request, include_untracked_accounts=True, include_silent=True
|
||||
)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_account(transactions)
|
||||
@@ -91,7 +93,9 @@ def sankey_by_account(request):
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request)
|
||||
transactions = get_transactions(
|
||||
request, include_silent=True, include_untracked_accounts=True
|
||||
)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_currency(transactions)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from crispy_forms.bootstrap import Alert
|
||||
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||
from apps.common.widgets.crispy.daisyui import Switch
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
@@ -36,7 +37,6 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
# TO-DO: Add helper with available commands
|
||||
self.helper.layout = Layout(
|
||||
Switch("active"),
|
||||
"name",
|
||||
@@ -49,6 +49,9 @@ class TransactionRuleForm(forms.ModelForm):
|
||||
Switch("sequenced"),
|
||||
"description",
|
||||
"trigger",
|
||||
Alert(
|
||||
_("You can add actions to this rule in the next screen."), dismiss=False
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
|
||||
@@ -20,7 +20,6 @@ from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import Signal
|
||||
from django.forms import ValidationError
|
||||
from django.template.defaultfilters import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -408,6 +407,10 @@ class Transaction(OwnedObject):
|
||||
self.reference_date = self.date.replace(day=1)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This is here so Django validation doesn't trigger an error before clean() is ran
|
||||
if not self.reference_date and self.date:
|
||||
self.reference_date = self.date.replace(day=1)
|
||||
|
||||
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||
# We only do this here because we forgot to independently call it on multiple places.
|
||||
self.full_clean()
|
||||
@@ -867,10 +870,8 @@ class RecurringTransaction(models.Model):
|
||||
notes=self.notes if self.add_notes_to_transaction else "",
|
||||
owner=self.account.owner,
|
||||
)
|
||||
if self.tags.exists():
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
if self.entities.exists():
|
||||
created_transaction.entities.set(self.entities.all())
|
||||
created_transaction.tags.set(self.tags.all())
|
||||
created_transaction.entities.set(self.entities.all())
|
||||
|
||||
def get_recurrence_delta(self):
|
||||
if self.recurrence_type == self.RecurrenceType.DAY:
|
||||
|
||||
@@ -13,7 +13,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.periodic(cron="0 0 * * *")
|
||||
@app.task(name="generate_recurring_transactions")
|
||||
@app.task(
|
||||
lock="generate_recurring_transactions", name="generate_recurring_transactions"
|
||||
)
|
||||
def generate_recurring_transactions(timestamp=None):
|
||||
try:
|
||||
RecurringTransaction.generate_upcoming_transactions()
|
||||
@@ -26,7 +28,7 @@ def generate_recurring_transactions(timestamp=None):
|
||||
|
||||
|
||||
@app.periodic(cron="10 1 * * *")
|
||||
@app.task(name="cleanup_deleted_transactions")
|
||||
@app.task(lock="cleanup_deleted_transactions", name="cleanup_deleted_transactions")
|
||||
def cleanup_deleted_transactions(timestamp=None):
|
||||
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
|
||||
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
|
||||
|
||||
0
app/apps/transactions/tests/__init__.py
Normal file
0
app/apps/transactions/tests/__init__.py
Normal file
@@ -1,9 +1,7 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.transactions.models import (
|
||||
174
app/apps/transactions/tests/test_views.py
Normal file
174
app/apps/transactions/tests/test_views.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class TransactionSimpleAddViewTests(TestCase):
|
||||
"""Tests for the transaction_simple_add view with query parameters"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
email="testuser@test.com", password="testpass123"
|
||||
)
|
||||
self.client.login(username="testuser@test.com", password="testpass123")
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.account_group = AccountGroup.objects.create(name="Test Group")
|
||||
self.account = Account.objects.create(
|
||||
name="Test Account", group=self.account_group, currency=self.currency
|
||||
)
|
||||
self.category = TransactionCategory.objects.create(name="Test Category")
|
||||
self.tag = TransactionTag.objects.create(name="TestTag")
|
||||
|
||||
def test_get_returns_form_with_default_values(self):
|
||||
"""Test GET request returns 200 and form with defaults"""
|
||||
response = self.client.get("/add/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("form", response.context)
|
||||
|
||||
def test_get_with_type_param(self):
|
||||
"""Test type param sets form initial value"""
|
||||
response = self.client.get("/add/?type=EX")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
|
||||
|
||||
def test_get_with_account_param(self):
|
||||
"""Test account param sets form initial value"""
|
||||
response = self.client.get(f"/add/?account={self.account.id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("account"), self.account.id)
|
||||
|
||||
def test_get_with_is_paid_param_true(self):
|
||||
"""Test is_paid param with true value"""
|
||||
response = self.client.get("/add/?is_paid=true")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertTrue(form.initial.get("is_paid"))
|
||||
|
||||
def test_get_with_is_paid_param_false(self):
|
||||
"""Test is_paid param with false value"""
|
||||
response = self.client.get("/add/?is_paid=false")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertFalse(form.initial.get("is_paid"))
|
||||
|
||||
def test_get_with_amount_param(self):
|
||||
"""Test amount param sets form initial value"""
|
||||
response = self.client.get("/add/?amount=150.50")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("amount"), "150.50")
|
||||
|
||||
def test_get_with_description_param(self):
|
||||
"""Test description param sets form initial value"""
|
||||
response = self.client.get("/add/?description=Test%20Transaction")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("description"), "Test Transaction")
|
||||
|
||||
def test_get_with_notes_param(self):
|
||||
"""Test notes param sets form initial value"""
|
||||
response = self.client.get("/add/?notes=Some%20notes")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("notes"), "Some notes")
|
||||
|
||||
def test_get_with_category_param(self):
|
||||
"""Test category param sets form initial value"""
|
||||
response = self.client.get(f"/add/?category={self.category.id}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("category"), self.category.id)
|
||||
|
||||
def test_get_with_tags_param(self):
|
||||
"""Test tags param as comma-separated names"""
|
||||
response = self.client.get("/add/?tags=TestTag,AnotherTag")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("tags"), ["TestTag", "AnotherTag"])
|
||||
|
||||
def test_get_with_all_params(self):
|
||||
"""Test all params together work correctly"""
|
||||
url = (
|
||||
f"/add/?type=EX&account={self.account.id}&is_paid=true"
|
||||
f"&amount=200.00&description=Full%20Test¬es=Test%20notes"
|
||||
f"&category={self.category.id}&tags=TestTag"
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
|
||||
self.assertEqual(form.initial.get("account"), self.account.id)
|
||||
self.assertTrue(form.initial.get("is_paid"))
|
||||
self.assertEqual(form.initial.get("amount"), "200.00")
|
||||
self.assertEqual(form.initial.get("description"), "Full Test")
|
||||
self.assertEqual(form.initial.get("notes"), "Test notes")
|
||||
self.assertEqual(form.initial.get("category"), self.category.id)
|
||||
self.assertEqual(form.initial.get("tags"), ["TestTag"])
|
||||
|
||||
def test_post_creates_transaction(self):
|
||||
"""Test form submission creates transaction"""
|
||||
data = {
|
||||
"account": self.account.id,
|
||||
"type": "EX",
|
||||
"is_paid": True,
|
||||
"date": timezone.now().date().isoformat(),
|
||||
"amount": "100.00",
|
||||
"description": "Test Transaction",
|
||||
}
|
||||
response = self.client.post("/add/", data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
Transaction.objects.filter(description="Test Transaction").exists()
|
||||
)
|
||||
|
||||
def test_get_with_date_param(self):
|
||||
"""Test date param overrides expected date"""
|
||||
response = self.client.get("/add/?date=2025-06-15")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("date"), date(2025, 6, 15))
|
||||
|
||||
def test_get_with_reference_date_param(self):
|
||||
"""Test reference_date param sets form initial value"""
|
||||
response = self.client.get("/add/?reference_date=2025-07-01")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("reference_date"), date(2025, 7, 1))
|
||||
|
||||
def test_get_with_account_name_param(self):
|
||||
"""Test account param by name (case-insensitive)"""
|
||||
response = self.client.get("/add/?account=Test%20Account")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("account"), self.account.id)
|
||||
|
||||
def test_get_with_category_name_param(self):
|
||||
"""Test category param by name (case-insensitive)"""
|
||||
response = self.client.get("/add/?category=Test%20Category")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
form = response.context["form"]
|
||||
self.assertEqual(form.initial.get("category"), self.category.id)
|
||||
@@ -142,26 +142,95 @@ def transaction_simple_add(request):
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
# Build initial data from query parameters
|
||||
initial_data = {
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
|
||||
# Handle date param (ISO format: YYYY-MM-DD) - overrides expected_date
|
||||
date_param = request.GET.get("date")
|
||||
if date_param:
|
||||
try:
|
||||
initial_data["date"] = datetime.datetime.strptime(date_param, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle reference_date param (ISO format: YYYY-MM-DD)
|
||||
reference_date_param = request.GET.get("reference_date")
|
||||
if reference_date_param:
|
||||
try:
|
||||
initial_data["reference_date"] = datetime.datetime.strptime(reference_date_param, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Handle account param (by ID or name)
|
||||
account_param = request.GET.get("account")
|
||||
if account_param:
|
||||
try:
|
||||
initial_data["account"] = int(account_param)
|
||||
except (ValueError, TypeError):
|
||||
# Try to find by name
|
||||
from apps.accounts.models import Account
|
||||
account = Account.objects.filter(name__iexact=account_param, is_archived=False).first()
|
||||
if account:
|
||||
initial_data["account"] = account.pk
|
||||
|
||||
# Handle is_paid param (boolean)
|
||||
is_paid = request.GET.get("is_paid")
|
||||
if is_paid is not None:
|
||||
initial_data["is_paid"] = is_paid.lower() in ("true", "1", "yes")
|
||||
|
||||
# Handle amount param (decimal)
|
||||
amount = request.GET.get("amount")
|
||||
if amount:
|
||||
try:
|
||||
initial_data["amount"] = amount
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Handle description param (string)
|
||||
description = request.GET.get("description")
|
||||
if description:
|
||||
initial_data["description"] = description
|
||||
|
||||
# Handle notes param (string)
|
||||
notes = request.GET.get("notes")
|
||||
if notes:
|
||||
initial_data["notes"] = notes
|
||||
|
||||
# Handle category param (by ID or name)
|
||||
category_param = request.GET.get("category")
|
||||
if category_param:
|
||||
try:
|
||||
initial_data["category"] = int(category_param)
|
||||
except (ValueError, TypeError):
|
||||
# Try to find by name
|
||||
from apps.transactions.models import TransactionCategory
|
||||
category = TransactionCategory.objects.filter(name__iexact=category_param, active=True).first()
|
||||
if category:
|
||||
initial_data["category"] = category.pk
|
||||
|
||||
# Handle tags param (comma-separated names)
|
||||
tags = request.GET.get("tags")
|
||||
if tags:
|
||||
initial_data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
# Handle entities param (comma-separated names)
|
||||
entities = request.GET.get("entities")
|
||||
if entities:
|
||||
initial_data["entities"] = [e.strip() for e in entities.split(",") if e.strip()]
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
# Only reset form after successful save
|
||||
form = TransactionForm(initial=initial_data)
|
||||
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
form = TransactionForm(initial=initial_data)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -137,13 +137,13 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.helper.layout = Layout(
|
||||
"language",
|
||||
"timezone",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"date_format",
|
||||
"datetime_format",
|
||||
"number_format",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"start_page",
|
||||
HTML("<hr />"),
|
||||
HTML('<hr class="hr my-3" />'),
|
||||
"volume",
|
||||
FormActions(
|
||||
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),
|
||||
|
||||
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
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
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
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{% load active_link %}
|
||||
{% load i18n %}
|
||||
<c-vars id="collapsible-panel" />
|
||||
|
||||
<li>
|
||||
<div role="button"
|
||||
_="on click toggle .hidden on #{{ id }} then toggle .slide-in-left on #{{ id }}"
|
||||
class="text-xs flex items-center no-underline ps-3 p-2 rounded-box sidebar-item cursor-pointer {% active_link views=active css_class='sidebar-active' %}">
|
||||
<i class="{{ icon }} fa-fw"></i>
|
||||
<span class="ml-3 font-medium lg:group-hover:truncate lg:group-focus:truncate lg:group-hover:text-ellipsis lg:group-focus:text-ellipsis">
|
||||
{{ title }}
|
||||
</span>
|
||||
<i class="fa-solid fa-chevron-right fa-fw ml-auto pe-2"></i>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<div id="{{ id }}"
|
||||
class="p-0 absolute bottom-0 left-0 w-full z-30 max-h-dvh {% active_link views=active css_class='slide-in-left' inactive_class='hidden' %}">
|
||||
<div class="h-dvh bg-base-300 flex flex-col">
|
||||
<div class="items-center p-4 border-b border-base-content/10 sidebar-submenu-header text-base-content">
|
||||
<div class="flex items-center sidebar-submenu-title">
|
||||
<i class="{{ icon }} fa-fw lg:group-hover:me-2 me-2 lg:me-0"></i>
|
||||
<h5 class="text-lg font-semibold text-base-content m-0">
|
||||
{{ title }}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{% trans 'Close' %}"
|
||||
_="on click remove .slide-in-left from #{{ id }} then add .slide-out-left to #{{ id }} then wait 150ms then add .hidden to #{{ id }} then remove .slide-out-left from #{{ id }}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-item-list list-none p-3 flex flex-col gap-1 whitespace-nowrap lg:group-hover:animate-[disable-pointer-events] overflow-y-auto lg:overflow-y-hidden lg:hover:overflow-y-auto overflow-x-hidden"
|
||||
style="animation-duration: 100ms">
|
||||
{{ slot }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,14 @@
|
||||
<li class="lg:hidden lg:group-hover:block">
|
||||
<div class="flex items-center" data-bs-toggle="collapse" href="#{{ title|slugify }}" role="button"
|
||||
aria-expanded="false" aria-controls="{{ title|slugify }}">
|
||||
<li class="lg:hidden lg:group-hover:block" x-data="{ open: false }">
|
||||
<div class="flex items-center" @click="open = !open" role="button"
|
||||
:aria-expanded="open">
|
||||
<span
|
||||
class="text-base-content/60 text-sm font-bold uppercase lg:hidden lg:group-hover:inline me-2">{{ title }}</span>
|
||||
<hr class="flex-grow"/>
|
||||
<i class="fas fa-chevron-down text-base-content/60 lg:before:hidden lg:group-hover:before:inline ml-2 lg:ml-0 lg:group-hover:ml-2"></i>
|
||||
<i class="fas fa-chevron-down text-base-content/60 lg:before:hidden lg:group-hover:before:inline ml-2 lg:ml-0 lg:group-hover:ml-2"
|
||||
:class="{ 'rotate-180': open }"
|
||||
style="transition: transform 0.2s ease"></i>
|
||||
</div>
|
||||
<div x-show="open" x-collapse>
|
||||
{{ slot }}
|
||||
</div>
|
||||
</li>
|
||||
<div class="collapse lg:hidden lg:group-hover:block" id="{{ title|slugify }}">
|
||||
{{ slot }}
|
||||
</div>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<div class="card bg-base-100 shadow-xl mb-2 transaction-item">
|
||||
<div class="card-body p-2 flex items-center gap-3" data-bs-toggle="collapse" data-bs-target="#{{ transaction.id }}" role="button" aria-expanded="false" aria-controls="{{ transaction.id }}">
|
||||
<!-- Main visible content -->
|
||||
<div class="flex flex-col lg:flex-row lg:items-center w-full gap-3">
|
||||
<!-- Type indicator -->
|
||||
<div class="w-8">
|
||||
{% if transaction.type == 'IN' %}
|
||||
<span class="badge badge-success">↑</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">↓</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Payment status -->
|
||||
<div class="w-8">
|
||||
{% if transaction.is_paid %}
|
||||
<span class="badge badge-success">✓</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">○</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="flex-grow">
|
||||
<span class="font-medium">{{ transaction.description }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="text-right whitespace-nowrap">
|
||||
<span class="{% if transaction.type == 'IN' %}text-green-400{% else %}text-red-400{% endif %}">
|
||||
{{ transaction.amount }}
|
||||
</span>
|
||||
{% if transaction.exchanged_amount %}
|
||||
<br>
|
||||
<small class="text-base-content/60">
|
||||
{{ transaction.exchanged_amount.prefix }}{{ transaction.exchanged_amount.amount }}{{ transaction.exchanged_amount.suffix }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expandable details -->
|
||||
<div class="collapse" id="{{ transaction.id }}">
|
||||
<div class="card-body p-3 transaction-details">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2">
|
||||
<div>
|
||||
<dl class="grid grid-cols-3">
|
||||
<dt class="col-span-1">Date</dt>
|
||||
<dd class="col-span-2">{{ transaction.date|date:"Y-m-d" }}</dd>
|
||||
|
||||
<dt class="col-span-1">Reference Date</dt>
|
||||
<dd class="col-span-2">{{ transaction.reference_date|date:"Y-m" }}</dd>
|
||||
|
||||
<dt class="col-span-1">Account</dt>
|
||||
<dd class="col-span-2">{{ transaction.account.name }}</dd>
|
||||
|
||||
<dt class="col-span-1">Category</dt>
|
||||
<dd class="col-span-2">{{ transaction.category|default:"-" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<dl class="grid grid-cols-3">
|
||||
{% if transaction.tags.exists %}
|
||||
<dt class="col-span-1">Tags</dt>
|
||||
<dd class="col-span-2">
|
||||
{% for tag in transaction.tags.all %}
|
||||
<span class="badge badge-secondary">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if transaction.installment_plan %}
|
||||
<dt class="col-span-1">Installment</dt>
|
||||
<dd class="col-span-2">
|
||||
{{ transaction.installment_id }} of {{ transaction.installment_plan.total_installments }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if transaction.recurring_transaction %}
|
||||
<dt class="col-span-1">Recurring</dt>
|
||||
<dd class="col-span-2">Yes</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if transaction.notes %}
|
||||
<dt class="col-span-1">Notes</dt>
|
||||
<dd class="col-span-2">{{ transaction.notes }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,11 @@
|
||||
{% load i18n %}
|
||||
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar"
|
||||
_="on change from #transactions-list or htmx:afterSettle from window
|
||||
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto 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
|
||||
add .slide-in-bottom-short-reverse then settle
|
||||
then add .hidden to #actions-bar
|
||||
then remove .slide-in-bottom-reverse
|
||||
then remove .slide-in-bottom-short-reverse
|
||||
end
|
||||
else
|
||||
if #actions-bar
|
||||
@@ -17,54 +16,51 @@
|
||||
end
|
||||
end
|
||||
end">
|
||||
<div class="card bg-base-300 shadow slide-in-bottom max-w-[90vw] card-border">
|
||||
<div class="card bg-base-300 shadow slide-in-bottom-short max-w-[90vw] card-border mt-5">
|
||||
<div class="card-body flex-row p-2 flex justify-between items-center gap-3 overflow-x-auto">
|
||||
{% spaceless %}
|
||||
<div class="font-bold text-md ms-2" id="selected-count">0</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div>
|
||||
<button role="button" class="btn btn-secondary btn-sm" type="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu menu">
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="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 text-error me-3"></i>{% translate 'Unselect All' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_undelete' %}"
|
||||
hx-include=".transaction"
|
||||
data-tippy-content="{% translate 'Restore' %}">
|
||||
<i class="fa-solid fa-trash-arrow-up fa-fw"></i>
|
||||
<div class="font-bold text-md ms-2" id="selected-count">0</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div>
|
||||
<button role="button" class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_delete' %}"
|
||||
hx-include=".transaction"
|
||||
hx-trigger="confirmed"
|
||||
data-tippy-content="{% 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-error"></i>
|
||||
</button>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join"
|
||||
_="on selected_transactions_updated from #actions-bar
|
||||
<ul class="dropdown-menu menu">
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="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 text-error me-3"></i>{% translate 'Unselect All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click for checkbox in <#transactions-list input[type='checkbox']/> set checkbox.checked to (not checkbox.checked) end then call me.blur() then trigger change">
|
||||
<i class="fa-solid fa-arrow-right-arrow-left text-info me-3"></i>{% translate 'Invert election' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_undelete' %}" hx-include=".transaction"
|
||||
data-tippy-content="{% 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-tippy-content="{% 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-error"></i>
|
||||
</button>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join" _="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)/>
|
||||
@@ -101,145 +97,121 @@
|
||||
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 join-item"
|
||||
_="on click
|
||||
<button class="btn btn-secondary btn-sm join-item" _="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"></i>
|
||||
<span class="hidden md:inline-block" id="real-total-front">0</span>
|
||||
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"></i>
|
||||
<span class="hidden md:inline-block" id="real-total-front">0</span>
|
||||
</button>
|
||||
<div>
|
||||
<button class="join-item btn btn-sm btn-secondary" type="button" data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
<div>
|
||||
<button class="join-item btn btn-sm btn-secondary"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end menu">
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
<ul class="dropdown-menu dropdown-menu-end menu">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-flat-total's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-flat-total
|
||||
wait 1s
|
||||
put original_value into #calc-menu-flat-total
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Flat Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-flat-total">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-flat-total wait 1s put original_value into
|
||||
#calc-menu-flat-total end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Flat Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-flat-total">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-real-total's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-real-total
|
||||
wait 1s
|
||||
put original_value into #calc-menu-real-total
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Real Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-real-total">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-real-total wait 1s put original_value into
|
||||
#calc-menu-real-total end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Real Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-real-total">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-mean's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-mean
|
||||
wait 1s
|
||||
put original_value into #calc-menu-mean
|
||||
end">
|
||||
<div class="p-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Mean" %}
|
||||
</div>
|
||||
<div id="calc-menu-mean">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-mean wait 1s put original_value into
|
||||
#calc-menu-mean end">
|
||||
<div class="p-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Mean" %}
|
||||
</div>
|
||||
<div id="calc-menu-mean">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-max's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-max
|
||||
wait 1s
|
||||
put original_value into #calc-menu-max
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Max" %}
|
||||
</div>
|
||||
<div id="calc-menu-max">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-max wait 1s put original_value into
|
||||
#calc-menu-max end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Max" %}
|
||||
</div>
|
||||
<div id="calc-menu-max">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-min's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-min
|
||||
wait 1s
|
||||
put original_value into #calc-menu-min
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Min" %}
|
||||
</div>
|
||||
<div id="calc-menu-min">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-min wait 1s put original_value into
|
||||
#calc-menu-min end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Min" %}
|
||||
</div>
|
||||
<div id="calc-menu-min">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-count's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-count
|
||||
wait 1s
|
||||
put original_value into #calc-menu-count
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Count" %}
|
||||
</div>
|
||||
<div id="calc-menu-count">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-count wait 1s put original_value into
|
||||
#calc-menu-count end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Count" %}
|
||||
</div>
|
||||
<div id="calc-menu-count">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,11 @@
|
||||
{% load i18n %}
|
||||
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar"
|
||||
_="on change from #transactions-list or htmx:afterSettle from window
|
||||
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto 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
|
||||
add .slide-in-bottom-short-reverse then settle
|
||||
then add .hidden to #actions-bar
|
||||
then remove .slide-in-bottom-reverse
|
||||
then remove .slide-in-bottom-short-reverse
|
||||
end
|
||||
else
|
||||
if #actions-bar
|
||||
@@ -17,86 +16,76 @@
|
||||
end
|
||||
end
|
||||
end">
|
||||
<div class="card bg-base-300 shadow slide-in-bottom max-w-[90vw] card-border">
|
||||
<div class="card bg-base-300 shadow slide-in-bottom-short max-w-[90vw] card-border mt-5">
|
||||
<div class="card-body flex-row p-2 flex justify-between items-center gap-3 overflow-x-auto">
|
||||
{% spaceless %}
|
||||
<div class="font-bold text-md ms-2" id="selected-count">0</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="font-bold text-md ms-2" id="selected-count">0</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div>
|
||||
<button role="button" class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu menu">
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="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 text-error me-3"></i>{% translate 'Unselect All' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click for checkbox in <#transactions-list input[type='checkbox']/> set checkbox.checked to (not checkbox.checked) end then call me.blur() then trigger change">
|
||||
<i class="fa-solid fa-arrow-right-arrow-left text-info me-3"></i>{% translate 'Invert selection' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join">
|
||||
<button class="btn btn-secondary join-item btn-sm" hx-get="{% url 'transactions_bulk_edit' %}"
|
||||
hx-target="#generic-offcanvas" hx-include=".transaction" data-tippy-content="{% translate 'Edit' %}">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</button>
|
||||
<div>
|
||||
<button role="button" class="btn btn-secondary btn-sm" type="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-regular fa-square-check fa-fw"></i>
|
||||
<button type="button" role="button" class="join-item btn btn-sm btn-secondary" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu menu">
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
|
||||
<a class="cursor-pointer" hx-get="{% url 'transactions_bulk_unpay' %}" hx-include=".transaction">
|
||||
<i class="fa-regular fa-circle text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="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 text-error me-3"></i>{% translate 'Unselect All' %}
|
||||
<a class="cursor-pointer" hx-get="{% url 'transactions_bulk_pay' %}" hx-include=".transaction">
|
||||
<i class="fa-regular fa-circle-check text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join">
|
||||
<button class="btn btn-secondary join-item btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_edit' %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-include=".transaction"
|
||||
data-tippy-content="{% translate 'Edit' %}">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</button>
|
||||
<div>
|
||||
<button type="button" role="button" class="join-item btn btn-sm btn-secondary"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu menu">
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
hx-get="{% url 'transactions_bulk_unpay' %}"
|
||||
hx-include=".transaction">
|
||||
<i class="fa-regular fa-circle text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="cursor-pointer"
|
||||
hx-get="{% url 'transactions_bulk_pay' %}"
|
||||
hx-include=".transaction">
|
||||
<i class="fa-regular fa-circle-check text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_clone' %}"
|
||||
hx-include=".transaction"
|
||||
data-tippy-content="{% translate 'Duplicate' %}">
|
||||
<i class="fa-solid fa-clone fa-fw"></i>
|
||||
</button>
|
||||
<button class="btn btn-error btn-sm"
|
||||
hx-get="{% url 'transactions_bulk_delete' %}"
|
||||
hx-include=".transaction"
|
||||
hx-trigger="confirmed"
|
||||
data-tippy-content="{% 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"></i>
|
||||
</button>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join"
|
||||
_="on selected_transactions_updated from #actions-bar
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_clone' %}" hx-include=".transaction"
|
||||
data-tippy-content="{% translate 'Duplicate' %}">
|
||||
<i class="fa-solid fa-clone fa-fw"></i>
|
||||
</button>
|
||||
<button class="btn btn-error btn-sm" hx-get="{% url 'transactions_bulk_delete' %}" hx-include=".transaction"
|
||||
hx-trigger="confirmed" data-tippy-content="{% 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"></i>
|
||||
</button>
|
||||
<div class="divider divider-horizontal m-0"></div>
|
||||
<div class="join" _="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)/>
|
||||
@@ -133,145 +122,121 @@
|
||||
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 join-item"
|
||||
_="on click
|
||||
<button class="btn btn-secondary btn-sm join-item" _="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"></i>
|
||||
<span class="hidden md:inline-block" id="real-total-front">0</span>
|
||||
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"></i>
|
||||
<span class="hidden md:inline-block" id="real-total-front">0</span>
|
||||
</button>
|
||||
<div>
|
||||
<button class="join-item btn btn-sm btn-secondary" type="button" data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside" aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
<div>
|
||||
<button class="join-item btn btn-sm btn-secondary"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-auto-close="outside"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-chevron-down fa-xs"></i>
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end menu">
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
<ul class="dropdown-menu dropdown-menu-end menu">
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-flat-total's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-flat-total
|
||||
wait 1s
|
||||
put original_value into #calc-menu-flat-total
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Flat Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-flat-total">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-flat-total wait 1s put original_value into
|
||||
#calc-menu-flat-total end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Flat Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-flat-total">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-real-total's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-real-total
|
||||
wait 1s
|
||||
put original_value into #calc-menu-real-total
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Real Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-real-total">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-real-total wait 1s put original_value into
|
||||
#calc-menu-real-total end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Real Total" %}
|
||||
</div>
|
||||
<div id="calc-menu-real-total">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-mean's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-mean
|
||||
wait 1s
|
||||
put original_value into #calc-menu-mean
|
||||
end">
|
||||
<div class="p-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Mean" %}
|
||||
</div>
|
||||
<div id="calc-menu-mean">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-mean wait 1s put original_value into
|
||||
#calc-menu-mean end">
|
||||
<div class="p-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Mean" %}
|
||||
</div>
|
||||
<div id="calc-menu-mean">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-max's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-max
|
||||
wait 1s
|
||||
put original_value into #calc-menu-max
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Max" %}
|
||||
</div>
|
||||
<div id="calc-menu-max">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-max wait 1s put original_value into
|
||||
#calc-menu-max end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Max" %}
|
||||
</div>
|
||||
<div id="calc-menu-max">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-min's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-min
|
||||
wait 1s
|
||||
put original_value into #calc-menu-min
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Min" %}
|
||||
</div>
|
||||
<div id="calc-menu-min">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-min wait 1s put original_value into
|
||||
#calc-menu-min end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Min" %}
|
||||
</div>
|
||||
<div id="calc-menu-min">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer"
|
||||
_="on click
|
||||
</div>
|
||||
</li>
|
||||
<li class="cursor-pointer" _="on click
|
||||
set original_value to #calc-menu-count's innerText
|
||||
writeText(original_value) on navigator.clipboard
|
||||
put '{% translate "copied!" %}' into #calc-menu-count
|
||||
wait 1s
|
||||
put original_value into #calc-menu-count
|
||||
end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Count" %}
|
||||
</div>
|
||||
<div id="calc-menu-count">
|
||||
0
|
||||
</div>
|
||||
put '{% translate "copied!" %}' into #calc-menu-count wait 1s put original_value into
|
||||
#calc-menu-count end">
|
||||
<div class="py-1 px-3">
|
||||
<div>
|
||||
<div class="text-base-content/60 text-xs font-medium">
|
||||
{% trans "Count" %}
|
||||
</div>
|
||||
<div id="calc-menu-count">
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="alert {{ alert.css_class }}" role="alert"{% if alert.css_id %} id="{{ alert.css_id }}"{% endif %}>
|
||||
{{ content|safe }}
|
||||
{% if dismiss %}<button type="button" class="btn btn-sm btn-circle btn-ghost" data-bs-dismiss="alert" aria-label="Close">✕</button>{% endif %}
|
||||
<span>{{ content|safe }}</span>
|
||||
{% if dismiss %}<button type="button" class="btn btn-sm btn-circle btn-ghost ml-auto" aria-label="Close" _="on click remove closest .alert">✕</button>{% endif %}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% if field.help_text %}
|
||||
{% if help_text_inline %}
|
||||
<span id="{{ field.auto_id }}_helptext" class="label text-wrap">{{ field.help_text|safe}}</span>
|
||||
<span id="{{ field.auto_id }}_helptext" class="label text-wrap block">{{ field.help_text|safe}}</span>
|
||||
{% else %}
|
||||
<p {% if field.auto_id %}id="{{ field.auto_id }}_helptext" {% endif %}class="label text-wrap">{{ field.help_text|safe }}</p>
|
||||
<p {% if field.auto_id %}id="{{ field.auto_id }}_helptext" {% endif %}class="label text-wrap block">{{ field.help_text|safe }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load crispy_forms_field %}
|
||||
{% load crispy_extra %}
|
||||
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
@@ -7,33 +8,43 @@
|
||||
|
||||
<fieldset class="fieldset{% if field_class %} {{ field_class }}{% endif %}">
|
||||
{% if field.label and form_show_labels %}
|
||||
<legend class="fieldset-legend{{ label_class }}{% if field.field.required %} requiredField{% endif %}">
|
||||
<label for="{{ field.id_for_label }}" class="fieldset-legend{% if label_class %} {{ label_class }}{% endif %}{% if field.field.required %} requiredField{% endif %}">
|
||||
{{ field.label }}{% if field.field.required %}<span class="asteriskField">*</span>{% endif %}
|
||||
</legend>
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<label class="{% if input_size %} {{ input_size }}{% endif %}{% if field.errors %} input-error{% endif %}">
|
||||
<div class="join w-full{% if input_size %} {{ input_size }}{% endif %}">
|
||||
{# prepend #}
|
||||
{% if crispy_prepended_text %}
|
||||
{{ crispy_prepended_text }}
|
||||
<span class="join-item flex items-center px-3 bg-base-200 border border-base-300">{{ crispy_prepended_text }}</span>
|
||||
{% endif %}
|
||||
|
||||
{# input #}
|
||||
{% if field|is_select %}
|
||||
{% if field.errors %}
|
||||
{% crispy_field field 'class' 'select-error grow' %}
|
||||
{% crispy_field field 'class' 'select select-error join-item grow' %}
|
||||
{% else %}
|
||||
{% crispy_field field 'class' 'grow' %}
|
||||
{% crispy_field field 'class' 'select join-item grow' %}
|
||||
{% endif %}
|
||||
{% elif field|is_input %}
|
||||
{% if field.errors %}
|
||||
{% crispy_field field 'class' 'input input-error join-item grow' %}
|
||||
{% else %}
|
||||
{% crispy_field field 'class' 'input join-item grow' %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% crispy_field field 'class' 'grow' %}
|
||||
{% if field.errors %}
|
||||
{% crispy_field field 'class' 'input input-error join-item grow' %}
|
||||
{% else %}
|
||||
{% crispy_field field 'class' 'input join-item grow' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{# append #}
|
||||
{% if crispy_appended_text %}
|
||||
{{ crispy_appended_text }}
|
||||
<span class="join-item flex items-center px-3 bg-base-200 border border-base-300">{{ crispy_appended_text }}</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# help text as label paragraph #}
|
||||
{% if not help_text_inline %}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
{% load cache_access %}
|
||||
{% load settings %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load active_link %}
|
||||
<nav class="navbar navbar-expand-lg border-bottom bg-body-tertiary" hx-boost="true">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold text-primary font-base" href="{% url 'index' %}">
|
||||
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="40" width="40" title="WYGIWYH"/>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
|
||||
aria-controls="navbarContent" aria-expanded="false" aria-label={% translate "Toggle navigation" %}>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarContent">
|
||||
<ul class="navbar-nav me-auto mb-3 mb-lg-0 nav-underline" hx-push-url="true">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='monthly_overview||yearly_overview_currency||yearly_overview_account||calendar' %}"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
{% translate 'Overview' %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item {% active_link views='monthly_overview' %}"
|
||||
href="{% url 'monthly_index' %}">{% translate 'Monthly' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='yearly_overview_currency' %}"
|
||||
href="{% url 'yearly_index_currency' %}">{% translate 'Yearly by currency' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='yearly_overview_account' %}"
|
||||
href="{% url 'yearly_index_account' %}">{% translate 'Yearly by account' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='calendar' %}"
|
||||
href="{% url 'calendar_index' %}">{% translate 'Calendar' %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='net_worth_current||net_worth_projected' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
{% translate 'Net Worth' %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item {% active_link views='net_worth_current' %}"
|
||||
href="{% url 'net_worth_current' %}">{% translate 'Current' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='net_worth_projected' %}"
|
||||
href="{% url 'net_worth_projected' %}">{% translate 'Projected' %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% active_link views='insights_index' %}" href="{% url 'insights_index' %}">{% trans 'Insights' %}</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||quick_transactions_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
{% translate 'Transactions' %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item {% active_link views='transactions_all_index' %}"
|
||||
href="{% url 'transactions_all_index' %}">{% translate 'All' %}</a></li>
|
||||
<li>
|
||||
{% 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='quick_transactions_index' %}"
|
||||
href="{% url 'quick_transactions_index' %}">{% translate 'Quick Transactions' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
|
||||
href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='recurring_trasanctions_index' %}"
|
||||
href="{% url 'recurring_trasanctions_index' %}">{% translate 'Recurring Transactions' %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='dca_strategy_index||dca_strategy_detail_index||unit_price_calculator||currency_converter' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
{% translate 'Tools' %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item {% active_link views='dca_strategy_index||dca_strategy_detail_index' %}"
|
||||
href="{% url 'dca_strategy_index' %}">{% translate 'Dollar Cost Average Tracker' %}</a></li>
|
||||
<li>
|
||||
<li><a class="dropdown-item {% active_link views='unit_price_calculator' %}"
|
||||
href="{% url 'unit_price_calculator' %}">{% translate 'Unit Price Calculator' %}</a></li>
|
||||
<li>
|
||||
<li><a class="dropdown-item {% active_link views='currency_converter' %}"
|
||||
href="{% url 'currency_converter' %}">{% translate 'Currency Converter' %}</a></li>
|
||||
<li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
{% translate 'Management' %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><h6 class="dropdown-header">{% trans 'Transactions' %}</h6></li>
|
||||
<li><a class="dropdown-item {% active_link views='categories_index' %}"
|
||||
href="{% url 'categories_index' %}">{% translate 'Categories' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='tags_index' %}"
|
||||
href="{% url 'tags_index' %}">{% translate 'Tags' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='entities_index' %}"
|
||||
href="{% url 'entities_index' %}">{% translate 'Entities' %}</a></li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li><h6 class="dropdown-header">{% trans 'Accounts' %}</h6></li>
|
||||
<li><a class="dropdown-item {% active_link views='accounts_index' %}"
|
||||
href="{% url 'accounts_index' %}">{% translate 'Accounts' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='account_groups_index' %}"
|
||||
href="{% url 'account_groups_index' %}">{% translate 'Account Groups' %}</a></li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li><h6 class="dropdown-header">{% trans 'Currencies' %}</h6></li>
|
||||
<li><a class="dropdown-item {% active_link views='currencies_index' %}"
|
||||
href="{% url 'currencies_index' %}">{% translate 'Currencies' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='exchange_rates_index' %}"
|
||||
href="{% url 'exchange_rates_index' %}">{% translate 'Exchange Rates' %}</a></li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li><h6 class="dropdown-header">{% trans 'Automation' %}</h6></li>
|
||||
<li><a class="dropdown-item {% active_link views='rules_index' %}"
|
||||
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>
|
||||
{% if user.is_superuser %}
|
||||
<li><a class="dropdown-item {% active_link views='export_index' %}"
|
||||
href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
|
||||
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
|
||||
{% if user.is_superuser %}
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li><h6 class="dropdown-header">{% trans 'Admin' %}</h6></li>
|
||||
<li><a class="dropdown-item {% active_link views='users_index' %}"
|
||||
href="{% url 'users_index' %}">{% translate 'Users' %}</a></li>
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{% url 'admin:index' %}"
|
||||
hx-boost="false"
|
||||
data-tippy-placement="right"
|
||||
data-tippy-content="{% translate "Only use this if you know what you're doing" %}">
|
||||
{% translate 'Django Admin' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav mb-2 mb-lg-0 gap-3">
|
||||
{% get_update_check as update_check %}
|
||||
{% if update_check.update_available %}
|
||||
<li class="nav-item my-auto">
|
||||
<a class="badge text-bg-secondary text-decoration-none cursor-pointer" href="https://github.com/eitchtee/WYGIWYH/releases/latest" target="_blank"><i class="fa-solid fa-circle-info fa-fw me-2"></i>v.{{ update_check.latest_version }} {% translate 'is available' %}!</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<div class="nav-link lg:text-2xl! cursor-pointer"
|
||||
data-tippy-placement="left" data-tippy-content="{% trans "Calculator" %}"
|
||||
_="on click trigger show on #calculator">
|
||||
<i class="fa-solid fa-calculator"></i>
|
||||
<span class="d-lg-none d-inline">{% trans "Calculator" %}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="w-100">{% include 'includes/navbar/user_menu.html' %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -10,9 +10,9 @@ behavior htmx_error_handler
|
||||
icon: 'warning',
|
||||
timer: 60000,
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-warning' -- Optional: different button style
|
||||
confirmButton: 'btn btn-warning'
|
||||
},
|
||||
buttonsStyling: true
|
||||
buttonsStyling: false
|
||||
})
|
||||
else
|
||||
call Swal.fire({
|
||||
@@ -23,7 +23,7 @@ behavior htmx_error_handler
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-primary'
|
||||
},
|
||||
buttonsStyling: true
|
||||
buttonsStyling: false
|
||||
})
|
||||
end
|
||||
then log event
|
||||
|
||||
@@ -135,138 +135,105 @@
|
||||
|
||||
<c-components.sidebar-menu-header title=""></c-components.sidebar-menu-header>
|
||||
|
||||
<div role="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#collapsible-panel"
|
||||
aria-expanded="false"
|
||||
aria-controls="collapsible-panel"
|
||||
class="text-xs flex items-center no-underline ps-3 p-2 rounded-box sidebar-item cursor-pointer {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="sidebar-active" %}">
|
||||
<i class="fa-solid fa-toolbox fa-fw"></i>
|
||||
<span class="ml-3 font-medium lg:group-hover:truncate lg:group-focus:truncate lg:group-hover:text-ellipsis lg:group-focus:text-ellipsis">
|
||||
{% translate 'Management' %}
|
||||
</span>
|
||||
<i class="fa-solid fa-chevron-right fa-fw ml-auto pe-2"></i>
|
||||
</div>
|
||||
<c-components.sidebar-collapsible-panel
|
||||
title="{% translate 'Management' %}"
|
||||
icon="fa-solid fa-toolbox"
|
||||
active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index">
|
||||
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Categories' %}"
|
||||
url='categories_index'
|
||||
active="categories_index"
|
||||
icon="fa-solid fa-icons">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Tags' %}"
|
||||
url='tags_index'
|
||||
active="tags_index"
|
||||
icon="fa-solid fa-hashtag">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Entities' %}"
|
||||
url='entities_index'
|
||||
active="entities_index"
|
||||
icon="fa-solid fa-user-group">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Accounts' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Accounts' %}"
|
||||
url='accounts_index'
|
||||
active="accounts_index"
|
||||
icon="fa-solid fa-wallet">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Account Groups' %}"
|
||||
url='account_groups_index'
|
||||
active="account_groups_index"
|
||||
icon="fa-solid fa-wallet">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Currencies' %}"
|
||||
url='currencies_index'
|
||||
active="currencies_index"
|
||||
icon="fa-solid fa-coins">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Exchange Rates' %}"
|
||||
url='exchange_rates_index'
|
||||
active="exchange_rates_index"
|
||||
icon="fa-solid fa-right-left">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Automation' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Rules' %}"
|
||||
url='rules_index'
|
||||
active="rules_index"
|
||||
icon="fa-solid fa-pen-ruler">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Import' %}"
|
||||
url='import_profiles_index'
|
||||
active="import_profiles_index"
|
||||
icon="fa-solid fa-file-import">
|
||||
</c-components.sidebar-menu-item>
|
||||
{% if user.is_superuser %}
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Export and Restore' %}"
|
||||
url='export_index'
|
||||
active="export_index"
|
||||
icon="fa-solid fa-file-export">
|
||||
</c-components.sidebar-menu-item>
|
||||
{% endif %}
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Automatic Exchange Rates' %}"
|
||||
url='automatic_exchange_rates_index'
|
||||
active="automatic_exchange_rates_index"
|
||||
icon="fa-solid fa-right-left">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<c-components.sidebar-menu-header title="{% translate 'Admin' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Users' %}"
|
||||
url='users_index'
|
||||
active="users_index"
|
||||
icon="fa-solid fa-users">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-url-item
|
||||
title="{% translate 'Django Admin' %}"
|
||||
tooltip="{% translate "Only use this if you know what you're doing" %}"
|
||||
url='/admin/'
|
||||
icon="fa-solid fa-screwdriver-wrench">
|
||||
</c-components.sidebar-menu-url-item>
|
||||
{% endif %}
|
||||
</c-components.sidebar-collapsible-panel>
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto p-2 w-full">
|
||||
<div id="collapsible-panel"
|
||||
class="bs collapse p-0 absolute bottom-0 left-0 w-full z-30 max-h-dvh {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="show" %}">
|
||||
<div class="h-dvh bg-base-300 flex flex-col">
|
||||
<div
|
||||
class="items-center p-4 border-b border-base-content/10 sidebar-submenu-header text-base-content">
|
||||
<div class="flex items-center sidebar-submenu-title">
|
||||
<i class="fa-solid fa-toolbox fa-fw lg:group-hover:me-2 me-2 lg:me-0"></i>
|
||||
<h5 class="text-lg font-semibold text-base-content m-0">
|
||||
{% trans 'Management' %}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{% trans 'Close' %}"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#collapsible-panel"
|
||||
aria-expanded="true"
|
||||
aria-controls="collapsible-panel">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-item-list list-none p-3 flex flex-col gap-1 whitespace-nowrap lg:group-hover:animate-[disable-pointer-events] overflow-y-auto lg:overflow-y-hidden lg:hover:overflow-y-auto overflow-x-hidden"
|
||||
style="animation-duration: 100ms">
|
||||
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Categories' %}"
|
||||
url='categories_index'
|
||||
active="categories_index"
|
||||
icon="fa-solid fa-icons">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Tags' %}"
|
||||
url='tags_index'
|
||||
active="tags_index"
|
||||
icon="fa-solid fa-hashtag">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Entities' %}"
|
||||
url='entities_index'
|
||||
active="entities_index"
|
||||
icon="fa-solid fa-user-group">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Accounts' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Accounts' %}"
|
||||
url='accounts_index'
|
||||
active="accounts_index"
|
||||
icon="fa-solid fa-wallet">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Account Groups' %}"
|
||||
url='account_groups_index'
|
||||
active="account_groups_index"
|
||||
icon="fa-solid fa-wallet">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Currencies' %}"
|
||||
url='currencies_index'
|
||||
active="currencies_index"
|
||||
icon="fa-solid fa-coins">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Exchange Rates' %}"
|
||||
url='exchange_rates_index'
|
||||
active="exchange_rates_index"
|
||||
icon="fa-solid fa-right-left">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
<c-components.sidebar-menu-header title="{% translate 'Automation' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Rules' %}"
|
||||
url='rules_index'
|
||||
active="rules_index"
|
||||
icon="fa-solid fa-pen-ruler">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Import' %}"
|
||||
url='import_profiles_index'
|
||||
active="import_profiles_index"
|
||||
icon="fa-solid fa-file-import">
|
||||
</c-components.sidebar-menu-item>
|
||||
{% if user.is_superuser %}
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Export and Restore' %}"
|
||||
url='export_index'
|
||||
active="export_index"
|
||||
icon="fa-solid fa-file-export">
|
||||
</c-components.sidebar-menu-item>
|
||||
{% endif %}
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Automatic Exchange Rates' %}"
|
||||
url='automatic_exchange_rates_index'
|
||||
active="automatic_exchange_rates_index"
|
||||
icon="fa-solid fa-right-left">
|
||||
</c-components.sidebar-menu-item>
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<c-components.sidebar-menu-header title="{% translate 'Admin' %}"></c-components.sidebar-menu-header>
|
||||
<c-components.sidebar-menu-item
|
||||
title="{% translate 'Users' %}"
|
||||
url='users_index'
|
||||
active="users_index"
|
||||
icon="fa-solid fa-users">
|
||||
</c-components.sidebar-menu-item>
|
||||
<c-components.sidebar-menu-url-item
|
||||
title="{% translate 'Django Admin' %}"
|
||||
tooltip="{% translate "Only use this if you know what you're doing" %}"
|
||||
url='/admin/'
|
||||
icon="fa-solid fa-screwdriver-wrench">
|
||||
</c-components.sidebar-menu-url-item>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% get_update_check as update_check %}
|
||||
{% if update_check.update_available %}
|
||||
<div class="my-3 sidebar-item">
|
||||
|
||||
@@ -5,52 +5,53 @@
|
||||
{% load title %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"
|
||||
data-theme="{% if request.session.theme == 'wygiwyh_light' %}wygiwyh_light{% else %}wygiwyh_dark{% endif %}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
{% filter site_title %}
|
||||
{% block title %}
|
||||
{% endblock title %}
|
||||
{% endfilter %}
|
||||
</title>
|
||||
{% include 'includes/head/favicons.html' %}
|
||||
{% progressive_web_app_meta %}
|
||||
{# {% include 'includes/styles.html' %}#}
|
||||
{% block extra_styles %}{% endblock %}
|
||||
{% include 'includes/scripts.html' %}
|
||||
{% block extra_js_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="font-mono">
|
||||
<div _="install htmx_error_handler
|
||||
{% block body_hyperscript %}{% endblock %}"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
{% include 'includes/mobile_navbar.html' %}
|
||||
{% include 'includes/sidebar.html' %}
|
||||
<main class="my-8 px-3">
|
||||
{% settings "DEMO" as demo_mode %}
|
||||
{% if demo_mode %}
|
||||
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
||||
<div class="alert alert-warning my-3" role="alert">
|
||||
<strong>{% trans "This is a demo!" %}</strong> {% trans "Any data you add here will be wiped in 24hrs or less" %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-ghost absolute right-2 top-2"
|
||||
onclick="this.parentElement.style.display='none'"
|
||||
aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="content">
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
data-theme="{% if request.session.theme == 'wygiwyh_light' %}wygiwyh_light{% else %}wygiwyh_dark{% endif %}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
{% filter site_title %}
|
||||
{% block title %}
|
||||
{% endblock title %}
|
||||
{% endfilter %}
|
||||
</title>
|
||||
{% include 'includes/head/favicons.html' %}
|
||||
{% progressive_web_app_meta %}
|
||||
{# {% include 'includes/styles.html' %}#}
|
||||
{% block extra_styles %}{% endblock %}
|
||||
{% include 'includes/scripts.html' %}
|
||||
{% block extra_js_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="font-mono">
|
||||
<div _="install htmx_error_handler
|
||||
{% block body_hyperscript %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
{% include 'includes/mobile_navbar.html' %}
|
||||
{% include 'includes/sidebar.html' %}
|
||||
<main class="my-8 px-3">
|
||||
{% settings "DEMO" as demo_mode %}
|
||||
{% if demo_mode %}
|
||||
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
||||
<div class="alert alert-warning my-3 relative" role="alert">
|
||||
<strong>{% trans "This is a demo!" %}</strong> {% trans "Any data you add here will be wiped in 24hrs or less"
|
||||
%}
|
||||
<button type="button" class="btn btn-sm btn-ghost absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onclick="this.parentElement.style.display='none'" aria-label="Close">✕</button>
|
||||
</div>
|
||||
{% include "includes/offcanvas.html" %}
|
||||
{% include "includes/toasts.html" %}
|
||||
</main>
|
||||
</div>
|
||||
{% include "includes/tools/calculator.html" %}
|
||||
{% block extra_js_body %}
|
||||
{% endblock extra_js_body %}
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="content">
|
||||
{% block content %}
|
||||
{% endblock content %}
|
||||
</div>
|
||||
{% include "includes/offcanvas.html" %}
|
||||
{% include "includes/toasts.html" %}
|
||||
</main>
|
||||
</div>
|
||||
{% include "includes/tools/calculator.html" %}
|
||||
{% block extra_js_body %}
|
||||
{% endblock extra_js_body %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -5,33 +5,19 @@
|
||||
<div id="transactions-list">
|
||||
{% for x in transactions_by_date %}
|
||||
<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')">
|
||||
x-data="{ open: sessionStorage.getItem('{{ x.grouper|slugify }}') !== 'false' }"
|
||||
x-init="if (sessionStorage.getItem('{{ x.grouper|slugify }}') === null) sessionStorage.setItem('{{ x.grouper|slugify }}', 'true')">
|
||||
<div class="mt-3 mb-1 w-full border-b border-b-base-content/30 transactions-divider-title cursor-pointer">
|
||||
<a class="no-underline inline-block w-full"
|
||||
role="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
|
||||
id="c-{{ x.grouper|slugify }}-collapsible"
|
||||
aria-expanded="false"
|
||||
aria-controls="c-{{ x.grouper|slugify }}-collapse">
|
||||
@click="open = !open; sessionStorage.setItem('{{ x.grouper|slugify }}', open)"
|
||||
:aria-expanded="open">
|
||||
{{ x.grouper }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="bs collapse transactions-divider-collapse overflow-visible isolation-auto" 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 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
|
||||
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="transactions-divider-collapse overflow-visible isolation-auto"
|
||||
x-show="open"
|
||||
x-collapse>
|
||||
<div class="flex flex-col">
|
||||
{% for transaction in x.list %}
|
||||
<c-transaction.item
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
<div class="col-12 lg:col-8 lg:order-first! order-last!">
|
||||
|
||||
<div class="my-3">
|
||||
<div class="my-3" x-data="{ filterOpen: false }" hx-preserve id="filter-container">
|
||||
{# Hidden select to hold the order value and preserve the original update trigger #}
|
||||
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
|
||||
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
||||
@@ -101,8 +101,8 @@
|
||||
<div class="join w-full">
|
||||
|
||||
<button class="btn btn-secondary join-item relative" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
|
||||
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
|
||||
@click="filterOpen = !filterOpen"
|
||||
:aria-expanded="filterOpen" id="filter-button"
|
||||
title="{% translate 'Filter transactions' %}">
|
||||
<i class="fa-solid fa-filter fa-fw"></i>
|
||||
</button>
|
||||
@@ -113,7 +113,6 @@
|
||||
<input type="search"
|
||||
class="input input-bordered join-item flex-1"
|
||||
placeholder="{% translate 'Search' %}"
|
||||
hx-preserve
|
||||
id="quick-search"
|
||||
_="on input or search or htmx:afterSwap from window
|
||||
if my value is empty
|
||||
@@ -165,7 +164,7 @@
|
||||
</div>
|
||||
|
||||
{# Filter transactions form #}
|
||||
<div class="bs collapse z-1" id="collapse-filter" hx-preserve>
|
||||
<div class="z-1" x-show="filterOpen" x-collapse>
|
||||
<div class="card card-body bg-base-200 mt-2">
|
||||
<div class="text-right">
|
||||
<button class="btn btn-outline btn-error btn-sm w-fit"
|
||||
|
||||
@@ -64,13 +64,13 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-col-auto">
|
||||
<a class="no-underline"
|
||||
<a class="no-underline cursor-pointer"
|
||||
role="button"
|
||||
data-tippy-content="
|
||||
{% if rule.active %}{% translate "Deactivate" %}{% else %}{% translate "Activate" %}{% endif %}"
|
||||
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}">
|
||||
{% if rule.active %}<i class="fa-solid fa-toggle-on text-green-400"></i>{% else %}
|
||||
<i class="fa-solid fa-toggle-off text-red-400"></i>{% endif %}
|
||||
{% if rule.active %}<i class="fa-solid fa-toggle-on text-success"></i>{% else %}
|
||||
<i class="fa-solid fa-toggle-off text-error"></i>{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td class="table-col-auto text-center">
|
||||
|
||||
@@ -110,8 +110,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr class="hr my-5">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 mt-5">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary w-full" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
@@ -138,7 +137,7 @@
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-primary w-full" type="button" data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
||||
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new action' %}
|
||||
</button>
|
||||
<ul class="dropdown-menu menu">
|
||||
<li><a role="link" href="#"
|
||||
|
||||
@@ -5,33 +5,19 @@
|
||||
<div id="transactions-list" class="show-loading">
|
||||
{% for x in transactions_by_date %}
|
||||
<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')">
|
||||
x-data="{ open: sessionStorage.getItem('{{ x.grouper|slugify }}') !== 'false' }"
|
||||
x-init="if (sessionStorage.getItem('{{ x.grouper|slugify }}') === null) sessionStorage.setItem('{{ x.grouper|slugify }}', 'true')">
|
||||
<div class="mt-3 mb-1 w-full border-b border-b-base-content/30 transactions-divider-title cursor-pointer">
|
||||
<a class="no-underline inline-block w-full"
|
||||
role="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
|
||||
id="c-{{ x.grouper|slugify }}-collapsible"
|
||||
aria-expanded="false"
|
||||
aria-controls="c-{{ x.grouper|slugify }}-collapse">
|
||||
@click="open = !open; sessionStorage.setItem('{{ x.grouper|slugify }}', open)"
|
||||
:aria-expanded="open">
|
||||
{{ x.grouper }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="bs collapse transactions-divider-collapse overflow-visible isolation-auto" 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 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
|
||||
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="transactions-divider-collapse overflow-visible isolation-auto"
|
||||
x-show="open"
|
||||
x-collapse>
|
||||
<div class="flex flex-col">
|
||||
{% for transaction in x.list %}
|
||||
<c-transaction.item
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<div class="col-12 lg:col-8 lg:order-first! order-last!">
|
||||
|
||||
<div>
|
||||
<div x-data="{ filterOpen: false }" hx-preserve id="filter-container">
|
||||
{# Hidden select to hold the order value and preserve the original update trigger #}
|
||||
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
|
||||
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
||||
@@ -53,8 +53,8 @@
|
||||
<div class="join w-full">
|
||||
|
||||
<button class="btn btn-secondary join-item relative" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
|
||||
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
|
||||
@click="filterOpen = !filterOpen"
|
||||
:aria-expanded="filterOpen" id="filter-button"
|
||||
title="{% translate 'Filter transactions' %}">
|
||||
<i class="fa-solid fa-filter fa-fw"></i>
|
||||
</button>
|
||||
@@ -65,7 +65,6 @@
|
||||
<input type="search"
|
||||
class="input input-bordered join-item flex-1"
|
||||
placeholder="{% translate 'Search' %}"
|
||||
hx-preserve
|
||||
id="quick-search"
|
||||
_="on input or search or htmx:afterSwap from window
|
||||
if my value is empty
|
||||
@@ -118,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
{# Filter transactions form #}
|
||||
<div class="bs collapse z-1" id="collapse-filter" hx-preserve>
|
||||
<div class="z-1" x-show="filterOpen" x-collapse>
|
||||
<div class="card card-body bg-base-200 mt-2">
|
||||
<div class="text-right">
|
||||
<button class="btn btn-outline btn-error btn-sm w-fit"
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
image: postgres:15-bookworm
|
||||
container_name: ${DB_NAME}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -74,5 +74,6 @@ RUN chown -R app:app /usr/src/app && \
|
||||
USER app
|
||||
|
||||
RUN python manage.py compilemessages --settings "WYGIWYH.settings"
|
||||
RUN python manage.py collectstatic --noinput
|
||||
|
||||
CMD ["/start-single"]
|
||||
|
||||
@@ -10,7 +10,6 @@ INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||
# 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
|
||||
|
||||
1
frontend/src/js/bootstrap.js
vendored
1
frontend/src/js/bootstrap.js
vendored
@@ -2,7 +2,6 @@ import './_tooltip.js';
|
||||
import 'bootstrap/js/dist/dropdown';
|
||||
import Toast from 'bootstrap/js/dist/toast';
|
||||
import 'bootstrap/js/dist/dropdown';
|
||||
import 'bootstrap/js/dist/collapse';
|
||||
import Offcanvas from 'bootstrap/js/dist/offcanvas';
|
||||
|
||||
window.Offcanvas = Offcanvas;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AirDatepicker from 'air-datepicker';
|
||||
import {createPopper} from '@popperjs/core';
|
||||
import { createPopper } from '@popperjs/core';
|
||||
import '../styles/_datepicker.scss'
|
||||
|
||||
// --- Static Locale Imports ---
|
||||
@@ -40,58 +40,58 @@ import localeZh from 'air-datepicker/locale/zh.js';
|
||||
|
||||
// Map language codes to their imported locale objects
|
||||
const allLocales = {
|
||||
'ar': localeAr,
|
||||
'bg': localeBg,
|
||||
'ca': localeCa,
|
||||
'cs': localeCs,
|
||||
'da': localeDa,
|
||||
'de': localeDe,
|
||||
'el': localeEl,
|
||||
'en': localeEn,
|
||||
'es': localeEs,
|
||||
'eu': localeEu,
|
||||
'fi': localeFi,
|
||||
'fr': localeFr,
|
||||
'hr': localeHr,
|
||||
'hu': localeHu,
|
||||
'id': localeId,
|
||||
'it': localeIt,
|
||||
'ja': localeJa,
|
||||
'ko': localeKo,
|
||||
'nb': localeNb,
|
||||
'nl': localeNl,
|
||||
'pl': localePl,
|
||||
'pt-BR': localePtBr,
|
||||
'pt': localePt,
|
||||
'ro': localeRo,
|
||||
'ru': localeRu,
|
||||
'si': localeSi,
|
||||
'sk': localeSk,
|
||||
'sl': localeSl,
|
||||
'sv': localeSv,
|
||||
'th': localeTh,
|
||||
'tr': localeTr,
|
||||
'uk': localeUk,
|
||||
'zh': localeZh
|
||||
'ar': localeAr,
|
||||
'bg': localeBg,
|
||||
'ca': localeCa,
|
||||
'cs': localeCs,
|
||||
'da': localeDa,
|
||||
'de': localeDe,
|
||||
'el': localeEl,
|
||||
'en': localeEn,
|
||||
'es': localeEs,
|
||||
'eu': localeEu,
|
||||
'fi': localeFi,
|
||||
'fr': localeFr,
|
||||
'hr': localeHr,
|
||||
'hu': localeHu,
|
||||
'id': localeId,
|
||||
'it': localeIt,
|
||||
'ja': localeJa,
|
||||
'ko': localeKo,
|
||||
'nb': localeNb,
|
||||
'nl': localeNl,
|
||||
'pl': localePl,
|
||||
'pt-BR': localePtBr,
|
||||
'pt': localePt,
|
||||
'ro': localeRo,
|
||||
'ru': localeRu,
|
||||
'si': localeSi,
|
||||
'sk': localeSk,
|
||||
'sl': localeSl,
|
||||
'sv': localeSv,
|
||||
'th': localeTh,
|
||||
'tr': localeTr,
|
||||
'uk': localeUk,
|
||||
'zh': localeZh
|
||||
};
|
||||
// --- End of Locale Imports ---
|
||||
|
||||
|
||||
/**
|
||||
* Selects a pre-imported language file from the locale map.
|
||||
*
|
||||
* @param {string} langCode - The two-letter language code (e.g., 'en', 'es').
|
||||
* @returns {Promise<object>} A promise that resolves with the locale object.
|
||||
*/
|
||||
* Selects a pre-imported language file from the locale map.
|
||||
*
|
||||
* @param {string} langCode - The two-letter language code (e.g., 'en', 'es').
|
||||
* @returns {Promise<object>} A promise that resolves with the locale object.
|
||||
*/
|
||||
export const getLocale = async (langCode) => {
|
||||
const locale = allLocales[langCode];
|
||||
const locale = allLocales[langCode];
|
||||
|
||||
if (locale) {
|
||||
return locale;
|
||||
}
|
||||
if (locale) {
|
||||
return locale;
|
||||
}
|
||||
|
||||
console.warn(`Could not find locale for '${langCode}'. Defaulting to English.`);
|
||||
return allLocales['en']; // Default to English
|
||||
console.warn(`Could not find locale for '${langCode}'. Defaulting to English.`);
|
||||
return allLocales['en']; // Default to English
|
||||
};
|
||||
|
||||
function isMobileDevice() {
|
||||
@@ -112,7 +112,7 @@ window.DatePicker = async function createDynamicDatePicker(element) {
|
||||
content: element.dataset.nowButtonTxt,
|
||||
onClick: (dp) => {
|
||||
let date = new Date();
|
||||
dp.selectDate(date, {updateTime: true});
|
||||
dp.selectDate(date, { updateTime: true });
|
||||
dp.setViewDate(date);
|
||||
}
|
||||
};
|
||||
@@ -126,16 +126,18 @@ window.DatePicker = async function createDynamicDatePicker(element) {
|
||||
autoClose: element.dataset.autoClose === 'true',
|
||||
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
|
||||
locale: await getLocale(element.dataset.language),
|
||||
onSelect: ({date, formattedDate, datepicker}) => {
|
||||
onSelect: ({ date, formattedDate, datepicker }) => {
|
||||
const _event = new CustomEvent("change", {
|
||||
bubbles: true,
|
||||
});
|
||||
datepicker.$el.dispatchEvent(_event);
|
||||
}
|
||||
};
|
||||
// Store popper instance for updating on view changes
|
||||
let popperInstance = null;
|
||||
const positionConfig = !isOnMobile ? {
|
||||
position({$datepicker, $target, $pointer, done}) {
|
||||
let popper = createPopper($target, $datepicker, {
|
||||
position({ $datepicker, $target, $pointer, done }) {
|
||||
popperInstance = createPopper($target, $datepicker, {
|
||||
placement: 'bottom',
|
||||
modifiers: [
|
||||
{
|
||||
@@ -157,16 +159,24 @@ window.DatePicker = async function createDynamicDatePicker(element) {
|
||||
options: {
|
||||
element: $pointer
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
return function completeHide() {
|
||||
popper.destroy();
|
||||
popperInstance.destroy();
|
||||
popperInstance = null;
|
||||
done();
|
||||
};
|
||||
},
|
||||
onChangeView() {
|
||||
// Update popper position when view changes (e.g., clicking year)
|
||||
// Use setTimeout to allow the DOM to update before recalculating
|
||||
if (popperInstance) {
|
||||
setTimeout(() => popperInstance.update(), 0);
|
||||
}
|
||||
}
|
||||
} : {};
|
||||
let opts = {...baseOpts, ...positionConfig};
|
||||
let opts = { ...baseOpts, ...positionConfig };
|
||||
if (element.dataset.value) {
|
||||
opts["selectedDates"] = [element.dataset.value];
|
||||
opts["startDate"] = [element.dataset.value];
|
||||
@@ -179,7 +189,7 @@ window.MonthYearPicker = async function createDynamicDatePicker(element) {
|
||||
content: element.dataset.nowButtonTxt,
|
||||
onClick: (dp) => {
|
||||
let date = new Date();
|
||||
dp.selectDate(date, {updateTime: true});
|
||||
dp.selectDate(date, { updateTime: true });
|
||||
dp.setViewDate(date);
|
||||
}
|
||||
};
|
||||
@@ -193,16 +203,18 @@ window.MonthYearPicker = async function createDynamicDatePicker(element) {
|
||||
autoClose: element.dataset.autoClose === 'true',
|
||||
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
|
||||
locale: await getLocale(element.dataset.language),
|
||||
onSelect: ({date, formattedDate, datepicker}) => {
|
||||
onSelect: ({ date, formattedDate, datepicker }) => {
|
||||
const _event = new CustomEvent("change", {
|
||||
bubbles: true,
|
||||
});
|
||||
datepicker.$el.dispatchEvent(_event);
|
||||
}
|
||||
};
|
||||
// Store popper instance for updating on view changes
|
||||
let popperInstance = null;
|
||||
const positionConfig = !isOnMobile ? {
|
||||
position({$datepicker, $target, $pointer, done}) {
|
||||
let popper = createPopper($target, $datepicker, {
|
||||
position({ $datepicker, $target, $pointer, done }) {
|
||||
popperInstance = createPopper($target, $datepicker, {
|
||||
placement: 'bottom',
|
||||
modifiers: [
|
||||
{
|
||||
@@ -228,12 +240,19 @@ window.MonthYearPicker = async function createDynamicDatePicker(element) {
|
||||
]
|
||||
});
|
||||
return function completeHide() {
|
||||
popper.destroy();
|
||||
popperInstance.destroy();
|
||||
popperInstance = null;
|
||||
done();
|
||||
};
|
||||
},
|
||||
onChangeView() {
|
||||
// Update popper position when view changes (e.g., clicking year)
|
||||
if (popperInstance) {
|
||||
setTimeout(() => popperInstance.update(), 0);
|
||||
}
|
||||
}
|
||||
} : {};
|
||||
let opts = {...baseOpts, ...positionConfig};
|
||||
let opts = { ...baseOpts, ...positionConfig };
|
||||
if (element.dataset.value) {
|
||||
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
|
||||
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
|
||||
@@ -246,7 +265,7 @@ window.YearPicker = async function createDynamicDatePicker(element) {
|
||||
content: element.dataset.nowButtonTxt,
|
||||
onClick: (dp) => {
|
||||
let date = new Date();
|
||||
dp.selectDate(date, {updateTime: true});
|
||||
dp.selectDate(date, { updateTime: true });
|
||||
dp.setViewDate(date);
|
||||
}
|
||||
};
|
||||
@@ -260,16 +279,18 @@ window.YearPicker = async function createDynamicDatePicker(element) {
|
||||
autoClose: element.dataset.autoClose === 'true',
|
||||
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
|
||||
locale: await getLocale(element.dataset.language),
|
||||
onSelect: ({date, formattedDate, datepicker}) => {
|
||||
onSelect: ({ date, formattedDate, datepicker }) => {
|
||||
const _event = new CustomEvent("change", {
|
||||
bubbles: true,
|
||||
});
|
||||
datepicker.$el.dispatchEvent(_event);
|
||||
}
|
||||
};
|
||||
// Store popper instance for updating on view changes
|
||||
let popperInstance = null;
|
||||
const positionConfig = !isOnMobile ? {
|
||||
position({$datepicker, $target, $pointer, done}) {
|
||||
let popper = createPopper($target, $datepicker, {
|
||||
position({ $datepicker, $target, $pointer, done }) {
|
||||
popperInstance = createPopper($target, $datepicker, {
|
||||
placement: 'bottom',
|
||||
modifiers: [
|
||||
{
|
||||
@@ -295,12 +316,19 @@ window.YearPicker = async function createDynamicDatePicker(element) {
|
||||
]
|
||||
});
|
||||
return function completeHide() {
|
||||
popper.destroy();
|
||||
popperInstance.destroy();
|
||||
popperInstance = null;
|
||||
done();
|
||||
};
|
||||
},
|
||||
onChangeView() {
|
||||
// Update popper position when view changes (e.g., clicking year)
|
||||
if (popperInstance) {
|
||||
setTimeout(() => popperInstance.update(), 0);
|
||||
}
|
||||
}
|
||||
} : {};
|
||||
let opts = {...baseOpts, ...positionConfig};
|
||||
let opts = { ...baseOpts, ...positionConfig };
|
||||
if (element.dataset.value) {
|
||||
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
|
||||
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
|
||||
|
||||
@@ -56,6 +56,22 @@
|
||||
animation: slide-in-left 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
||||
}
|
||||
|
||||
@keyframes slide-out-left {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-out-left {
|
||||
animation: slide-out-left 0.15s cubic-bezier(0.55, 0.085, 0.68, 0.53) both;
|
||||
}
|
||||
|
||||
// HTMX Loading
|
||||
@keyframes spin {
|
||||
0% {
|
||||
@@ -248,6 +264,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ----------------------------------------
|
||||
* animation slide-in-bottom-short
|
||||
* A variant with smaller translateY for elements at bottom of viewport
|
||||
* ----------------------------------------
|
||||
*/
|
||||
@keyframes slide-in-bottom-short {
|
||||
0% {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in-bottom-short {
|
||||
animation: slide-in-bottom-short 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
|
||||
}
|
||||
|
||||
.slide-in-bottom-short-reverse {
|
||||
animation: slide-in-bottom-short 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) reverse both;
|
||||
}
|
||||
|
||||
@keyframes disable-pointer-events {
|
||||
|
||||
0%,
|
||||
|
||||
@@ -323,4 +323,4 @@ $breakpoints: (
|
||||
|
||||
.offcanvas-size-sm {
|
||||
--offcanvas-width: min(95vw, 250px);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ $enable-transitions: true !default;
|
||||
$enable-reduced-motion: true !default;
|
||||
|
||||
$transition-fade: opacity 0.15s linear !default;
|
||||
$transition-collapse: height 0.35s ease !default;
|
||||
$transition-collapse-width: width 0.35s ease !default;
|
||||
|
||||
// Fade transition
|
||||
.fade {
|
||||
@@ -22,35 +20,4 @@ $transition-collapse-width: width 0.35s ease !default;
|
||||
&:not(.show) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// // Collapse transitions
|
||||
.bs.collapse {
|
||||
&:not(.show) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bs.collapsing {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
transition: $transition-collapse;
|
||||
|
||||
@if $enable-reduced-motion {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse-horizontal {
|
||||
width: 0;
|
||||
height: auto;
|
||||
transition: $transition-collapse-width;
|
||||
|
||||
@if $enable-reduced-motion {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,6 @@ select {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-bs-toggle="collapse"] .fa-chevron-down {
|
||||
transition: transform 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
div:where(.swal2-container) {
|
||||
z-index: 1101 !important;
|
||||
}
|
||||
@@ -85,4 +77,4 @@ div:where(.swal2-container) {
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -309,13 +309,15 @@
|
||||
}
|
||||
|
||||
.sidebar-fixed {
|
||||
/* Sets the fixed, expanded width for the container */
|
||||
@apply lg:w-[17%] transition-all duration-100;
|
||||
/* Sets the fixed, expanded width for the container.
|
||||
Using fixed rem width instead of percentage to prevent width inconsistencies
|
||||
caused by scrollbar presence affecting viewport width calculations. */
|
||||
@apply lg:w-80 transition-all duration-100;
|
||||
}
|
||||
|
||||
.sidebar-fixed #sidebar {
|
||||
/* Sets the fixed, expanded width for the inner navigation */
|
||||
@apply lg:w-[17%] transition-all duration-100;
|
||||
@apply lg:w-80 transition-all duration-100;
|
||||
}
|
||||
|
||||
.sidebar-fixed .sidebar-item-list {
|
||||
@@ -324,9 +326,7 @@
|
||||
|
||||
.sidebar-fixed + main {
|
||||
/* Adjusts the main content margin to account for the expanded sidebar */
|
||||
@apply lg:ml-[17%];
|
||||
|
||||
/* Using 16vw to account for padding/margins */
|
||||
@apply lg:ml-80;
|
||||
}
|
||||
|
||||
.sidebar-fixed .sidebar-item {
|
||||
|
||||
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
port: parseInt(process.env.VITE_DEV_SERVER_PORT || '5173'),
|
||||
open: false,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
@@ -37,7 +37,7 @@ export default defineConfig({
|
||||
},
|
||||
hmr: false,
|
||||
cors: true,
|
||||
origin: 'http://localhost:5173'
|
||||
origin: `http://${process.env.VITE_DEV_SERVER_HOST || 'localhost'}:${process.env.VITE_DEV_SERVER_PORT || '5173'}`
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
Django~=5.2
|
||||
psycopg[binary]==3.2.9
|
||||
Django~=5.2.9
|
||||
psycopg[binary,pool]==3.2.9
|
||||
django-vite==3.1.0
|
||||
django-crispy-forms==2.4
|
||||
django-crispy-forms==2.5
|
||||
crispy-bootstrap5==2025.6
|
||||
django-browser-reload==1.18.0
|
||||
django-hijack==3.7.3
|
||||
django-filter==25.1
|
||||
django-debug-toolbar==4.4.6
|
||||
django-browser-reload==1.21.0
|
||||
django-hijack==3.7.4
|
||||
django-filter==25.2
|
||||
django-debug-toolbar==6.1.0
|
||||
django-cachalot~=2.8.0
|
||||
django-cotton~=2.1.3
|
||||
django-cotton<2.3.0
|
||||
django-pwa~=2.0.1
|
||||
djangorestframework~=3.16.0
|
||||
drf-spectacular~=0.28.0
|
||||
drf-spectacular~=0.29.0
|
||||
django-import-export~=4.3.9
|
||||
|
||||
gunicorn==23.0.0
|
||||
whitenoise[brotli]==6.9.0
|
||||
whitenoise[brotli]==6.11.0
|
||||
|
||||
watchfiles==1.1.0 # https://github.com/samuelcolvin/watchfiles
|
||||
procrastinate[django]~=3.4.0
|
||||
watchfiles==1.1.1
|
||||
procrastinate[django]~=3.5.3
|
||||
|
||||
requests~=2.32.3
|
||||
django-allauth[socialaccount]~=65.10.0
|
||||
requests~=2.32.5
|
||||
django-allauth[socialaccount]~=65.13.1
|
||||
|
||||
pytz
|
||||
python-dateutil~=2.9.0.post0
|
||||
simpleeval~=1.0.3
|
||||
pydantic~=2.11.3
|
||||
pydantic~=2.12.3
|
||||
PyYAML~=6.0.2
|
||||
mistune~=3.1.3
|
||||
openpyxl~=3.1.5
|
||||
|
||||
Reference in New Issue
Block a user