Compare commits

..

47 Commits

Author SHA1 Message Date
Herculino Trotta
585652064a locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (617 of 617 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-31 05:38:18 +00:00
eitchtee
ea6f61d5e4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-31 05:30:21 +00:00
Herculino Trotta
e986f7d802 Merge pull request #224
feat: add demo mode and allow for automatic admin creation from env variables
2025-03-31 02:29:44 -03:00
Herculino Trotta
26b218ae51 feat(app): disable API when demo mode is enabled 2025-03-31 02:28:48 -03:00
Herculino Trotta
19f0bc1034 feat(app): show current user e-mail on user menu 2025-03-31 02:28:33 -03:00
Herculino Trotta
47d34f3c27 feat(app): add a demo mode 2025-03-31 02:14:00 -03:00
Herculino Trotta
046e02d506 feat(app): add environment variables to automatically create superuser on startup 2025-03-31 02:11:13 -03:00
valentin-p
92c7a29b6a locale(German): update translation
Currently translated at 99.8% (610 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-30 15:07:17 +00:00
valentin-p
d95e5f71cc locale((French)): added translation using Weblate 2025-03-30 13:40:51 +00:00
Herculino Trotta
992c518dab Merge pull request #222
fix(net-worth): non-used currencies showing up on charts
2025-03-23 01:35:14 -03:00
Herculino Trotta
29aa1c9d2b fix(net-worth): non-used currencies showing up on charts 2025-03-23 01:34:53 -03:00
Herculino Trotta
1b3b7a583d Merge pull request #220 from eitchtee/dependabot/pip/gunicorn-23.0.0
chore(deps): bump gunicorn from 22.0.0 to 23.0.0
2025-03-22 01:02:43 -03:00
dependabot[bot]
2d22f961ad chore(deps): bump gunicorn from 22.0.0 to 23.0.0
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 22.0.0 to 23.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/22.0.0...23.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-22 04:01:48 +00:00
Herculino Trotta
71551d7651 Merge pull request #219
fix(insights:category-explorer): category field not loading available categories correctly
2025-03-15 11:37:46 -03:00
Herculino Trotta
62d58d1be3 fix(insights:category-explorer): category field not loading available categories correctly 2025-03-15 11:37:28 -03:00
Herculino Trotta
21917437f2 Merge pull request #218
fix(tools:currency-converter): currency list displaying oldest result instead of newest
2025-03-13 22:18:21 -03:00
Herculino Trotta
59acb14d05 fix(tools:currency-converter): currency list displaying oldest result instead of newest 2025-03-13 22:18:02 -03:00
Dimitri Decrock
050f794f2b locale(Dutch): update translation
Currently translated at 100.0% (611 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-13 07:05:35 +00:00
Schmitz Schmitz
a5958c0937 locale(German): update translation
Currently translated at 99.6% (609 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/de/
2025-03-10 09:05:35 +00:00
Herculino Trotta
ee73ada5ae Merge pull request #215
fix: missing selection when updating transactions in a transaction list
2025-03-09 20:22:18 -03:00
Herculino Trotta
736a116685 fix: missing selection when updating transactions in a transaction list 2025-03-09 20:21:48 -03:00
Herculino Trotta
6c03c7b4eb locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (611 of 611 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-09 23:10:59 +00:00
eitchtee
960e537709 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 21:56:43 +00:00
Herculino Trotta
e32285ce75 Merge pull request #214
feat: alphabetically order most models by default
2025-03-09 18:56:10 -03:00
Herculino Trotta
73e8fdbf04 feat: alphabetically order most models by default
#207
2025-03-09 18:55:29 -03:00
Herculino Trotta
d4c15da051 Merge pull request #212
feat(monthly_overview): preserve filter between month changes
2025-03-09 18:46:38 -03:00
Herculino Trotta
187b3174d2 feat(monthly_overview): preserve filter between month changes
#208
2025-03-09 18:45:55 -03:00
eitchtee
c90ea7ef16 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 20:55:47 +00:00
Herculino Trotta
54713ecfe2 Merge pull request #211
fix(transactions:transfer): remove required description field
2025-03-09 17:55:15 -03:00
Herculino Trotta
cf693aa0c3 fix(transactions:transfer): remove required description field
#209
2025-03-09 17:54:16 -03:00
eitchtee
3580f1b132 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 20:51:39 +00:00
Herculino Trotta
febd9a8ae7 Merge pull request #210
feat(transactions): add option for removing Recurring/Installment descriptions and notes from generated transactions
2025-03-09 17:50:59 -03:00
Herculino Trotta
3809f82b60 feat(transactions): add option for removing Recurring/Installment descriptions and notes from generated transactions
#209
2025-03-09 17:50:27 -03:00
Dimitri Decrock
3c6b52462a locale(Dutch): update translation
Currently translated at 100.0% (609 of 609 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-03-09 12:05:35 +00:00
Herculino Trotta
cc8a4c97a9 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (609 of 609 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-09 04:56:21 +00:00
eitchtee
99fbb5f7db chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 04:55:05 +00:00
Herculino Trotta
3d61068ecf Merge pull request #206
feat(rules): trigger transaction rules on delete
2025-03-09 01:54:28 -03:00
Herculino Trotta
f6f06f4d65 feat(rules): trigger transaction rules on delete 2025-03-09 01:54:03 -03:00
eitchtee
56346c26ee chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 04:27:30 +00:00
Herculino Trotta
23b74d73e5 Merge pull request #205
fix(rules): unable to save
2025-03-09 01:26:57 -03:00
Herculino Trotta
17697dc565 fix(rules): unable to save 2025-03-09 01:26:42 -03:00
Herculino Trotta
e9bc35d9b2 Merge pull request #204
fix(api): re-order transactions from newest to oldest
2025-03-08 23:23:25 -03:00
Herculino Trotta
d6fbb71f41 fix(api): re-order transactions from newest to oldest 2025-03-08 23:23:07 -03:00
eitchtee
9a9cf75bcd chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-03-09 01:10:53 +00:00
Herculino Trotta
d6a8658fe1 Merge pull request #203
fix(api): unable to create transaction
2025-03-08 22:09:40 -03:00
Herculino Trotta
211963ea7d fix(api): unable to create transaction 2025-03-08 22:09:24 -03:00
Herculino Trotta
776068a438 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (608 of 608 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-03-08 18:05:35 +00:00
50 changed files with 4910 additions and 1114 deletions

View File

@@ -10,6 +10,11 @@ SECRET_KEY=<GENERATE A SAFE SECRET KEY AND PLACE IT HERE>
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
OUTBOUND_PORT=9005
# Uncomment these variables to automatically create an admin account using these credentials on startup.
# After your first successfull login you can remove these variables from your file for safety reasons.
#ADMIN_EMAIL=<ENTER YOUR EMAIL>
#ADMIN_PASSWORD=<YOUR SAFE PASSWORD>
SQL_DATABASE=wygiwyh
SQL_USER=wygiwyh
SQL_PASSWORD=<INSERT A SAFE PASSWORD HERE>

View File

@@ -76,7 +76,7 @@ $ nano .env # or any other editor you want to use
# Run the app
$ docker compose up -d
# Create the first admin account
# Create the first admin account. This isn't required if you set the enviroment variables: ADMIN_EMAIL and ADMIN_PASSWORD.
$ docker compose exec -it web python manage.py createsuperuser
```
@@ -117,7 +117,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 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 |
| 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 |
| SECRET_KEY | string | "" | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. |
| DEBUG | true\|false | false | Turns DEBUG mode on or off, this is useful to gather more data about possible errors you're having. Don't use in production. |
| SQL_DATABASE | string | None *required | The name of your postgres database |
@@ -129,6 +129,9 @@ To create the first user, open the container's console using Unraid's UI, by cli
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| 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. |
# How it works

View File

@@ -261,7 +261,10 @@ if DEBUG:
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.DjangoModelPermissions"],
"DEFAULT_PERMISSION_CLASSES": [
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 10,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@@ -394,3 +397,4 @@ PWA_SERVICE_WORKER_PATH = BASE_DIR / "templates" / "pwa" / "serviceworker.js"
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))
APP_VERSION = os.getenv("APP_VERSION", "unknown")
DEMO = os.getenv("DEMO_MODE", "false").lower() == "true"

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-03-09 21:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0013_alter_account_visibility_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='account',
options={'ordering': ['name', 'id'], 'verbose_name': 'Account', 'verbose_name_plural': 'Accounts'},
),
migrations.AlterModelOptions(
name='accountgroup',
options={'ordering': ['name', 'id'], 'verbose_name': 'Account Group', 'verbose_name_plural': 'Account Groups'},
),
]

View File

@@ -19,6 +19,7 @@ class AccountGroup(SharedObject):
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
@@ -69,6 +70,7 @@ class Account(SharedObject):
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name

View File

@@ -0,0 +1,10 @@
from rest_framework.permissions import BasePermission
from django.conf import settings
class NotInDemoMode(BasePermission):
def has_permission(self, request, view):
if settings.DEMO and not request.user.is_superuser:
return False
else:
return True

View File

@@ -23,6 +23,7 @@ from apps.transactions.models import (
TransactionEntity,
RecurringTransaction,
)
from apps.common.middleware.thread_local import get_current_user
class TransactionCategorySerializer(serializers.ModelSerializer):
@@ -31,6 +32,10 @@ class TransactionCategorySerializer(serializers.ModelSerializer):
class Meta:
model = TransactionCategory
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionTagSerializer(serializers.ModelSerializer):
@@ -39,6 +44,10 @@ class TransactionTagSerializer(serializers.ModelSerializer):
class Meta:
model = TransactionTag
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class TransactionEntitySerializer(serializers.ModelSerializer):
@@ -47,6 +56,10 @@ class TransactionEntitySerializer(serializers.ModelSerializer):
class Meta:
model = TransactionEntity
fields = "__all__"
read_only_fields = [
"id",
"owner",
]
class InstallmentPlanSerializer(serializers.ModelSerializer):
@@ -157,8 +170,16 @@ class TransactionSerializer(serializers.ModelSerializer):
"installment_plan",
"recurring_transaction",
"installment_id",
"owner",
"deleted_at",
"deleted",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account_id"].queryset = Account.objects.all()
def validate(self, data):
if not self.partial:
if "date" in data and "reference_date" not in data:

View File

@@ -20,6 +20,8 @@ class AccountViewSet(viewsets.ModelViewSet):
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return Account.objects.all().select_related(
"group", "currency", "exchange_currency"
return (
Account.objects.all()
.order_by("id")
.select_related("group", "currency", "exchange_currency")
)

View File

@@ -38,7 +38,7 @@ class TransactionViewSet(viewsets.ModelViewSet):
return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("id")
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet):
@@ -51,7 +51,7 @@ class TransactionCategoryViewSet(viewsets.ModelViewSet):
class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all().order_by("id")
queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer
pagination_class = CustomPageNumberPagination
@@ -60,7 +60,7 @@ class TransactionTagViewSet(viewsets.ModelViewSet):
class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all().order_by("id")
queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer
pagination_class = CustomPageNumberPagination
@@ -69,18 +69,18 @@ class TransactionEntityViewSet(viewsets.ModelViewSet):
class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all().order_by("id")
queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return InstallmentPlan.objects.all().order_by("id")
return InstallmentPlan.objects.all().order_by("-id")
class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all().order_by("id")
queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer
pagination_class = CustomPageNumberPagination
def get_queryset(self):
return RecurringTransaction.objects.all().order_by("id")
return RecurringTransaction.objects.all().order_by("-id")

View File

@@ -0,0 +1,15 @@
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
def disabled_on_demo(view):
@wraps(view)
def _view(request, *args, **kwargs):
if settings.DEMO and not request.user.is_superuser:
raise PermissionDenied
return view(request, *args, **kwargs)
return _view

View File

View File

@@ -0,0 +1,137 @@
import os
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import IntegrityError
# Get the custom User model if defined, otherwise the default User model
User = get_user_model()
class Command(BaseCommand):
help = (
"Creates a superuser from environment variables (ADMIN_EMAIL, ADMIN_PASSWORD) "
"and optionally creates a demo user (demo@demo.com) if settings.DEMO is True."
)
def handle(self, *args, **options):
self.stdout.write("Starting user setup...")
# --- Create Superuser ---
admin_email = os.environ.get("ADMIN_EMAIL")
admin_password = os.environ.get("ADMIN_PASSWORD")
if admin_email and admin_password:
self.stdout.write(f"Attempting to create superuser: {admin_email}")
# Use email as username for simplicity, requires USERNAME_FIELD='email'
# or adapt if your USERNAME_FIELD is different.
# If USERNAME_FIELD is 'username', you might need ADMIN_USERNAME env var.
username_field = User.USERNAME_FIELD # Get the actual username field name
# Check if the user already exists by email or username
user_exists_kwargs = {"email": admin_email}
if username_field != "email":
# Assume username should also be the email if not explicitly provided
user_exists_kwargs[username_field] = admin_email
if User.objects.filter(**user_exists_kwargs).exists():
self.stdout.write(
self.style.WARNING(
f"Superuser with email '{admin_email}' (or corresponding username) already exists. Skipping creation."
)
)
else:
try:
create_kwargs = {
username_field: admin_email, # Use email as username by default
"email": admin_email,
"password": admin_password,
}
User.objects.create_superuser(**create_kwargs)
self.stdout.write(
self.style.SUCCESS(
f"Superuser '{admin_email}' created successfully."
)
)
except IntegrityError as e:
self.stdout.write(
self.style.ERROR(
f"Failed to create superuser '{admin_email}'. IntegrityError: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"An unexpected error occurred creating superuser '{admin_email}': {e}"
)
)
else:
self.stdout.write(
self.style.NOTICE(
"ADMIN_EMAIL or ADMIN_PASSWORD environment variables not set. Skipping superuser creation."
)
)
self.stdout.write("---") # Separator
# --- Create Demo User ---
# Use getattr to safely check for the DEMO setting, default to False if not present
create_demo_user = getattr(settings, "DEMO", False)
if create_demo_user:
demo_email = "demo@demo.com"
demo_password = (
"wygiwyhdemo" # Consider making this an env var too for security
)
demo_username = demo_email # Using email as username for consistency
self.stdout.write(
f"DEMO setting is True. Attempting to create demo user: {demo_email}"
)
username_field = User.USERNAME_FIELD # Get the actual username field name
# Check if the user already exists by email or username
user_exists_kwargs = {"email": demo_email}
if username_field != "email":
user_exists_kwargs[username_field] = demo_username
if User.objects.filter(**user_exists_kwargs).exists():
self.stdout.write(
self.style.WARNING(
f"Demo user with email '{demo_email}' (or corresponding username) already exists. Skipping creation."
)
)
else:
try:
create_kwargs = {
username_field: demo_username,
"email": demo_email,
"password": demo_password,
}
User.objects.create_user(**create_kwargs)
self.stdout.write(
self.style.SUCCESS(
f"Demo user '{demo_email}' created successfully."
)
)
except IntegrityError as e:
self.stdout.write(
self.style.ERROR(
f"Failed to create demo user '{demo_email}'. IntegrityError: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(
f"An unexpected error occurred creating demo user '{demo_email}': {e}"
)
)
else:
self.stdout.write(
self.style.NOTICE(
"DEMO setting is not True (or not set). Skipping demo user creation."
)
)
self.stdout.write(self.style.SUCCESS("User setup command finished."))

View File

@@ -1,7 +1,9 @@
import logging
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core import management
from django.db import DEFAULT_DB_ALIAS
from procrastinate import builtin_tasks
from procrastinate.contrib.django import app
@@ -40,3 +42,39 @@ async def remove_expired_sessions(timestamp=None):
"Error while executing 'remove_expired_sessions' task",
exc_info=True,
)
@app.periodic(cron="0 6 * * *")
@app.task(name="reset_demo_data")
def reset_demo_data():
"""
Wipes the database and loads fresh demo data if DEMO mode is active.
Runs daily at 6:00 AM.
"""
if not settings.DEMO:
return # Exit if not in demo mode
logger.info("Demo mode active. Starting daily data reset...")
try:
# 1. Flush the database (wipe all data)
logger.info("Flushing the database...")
# Using --noinput prevents prompts. Specify database if not default.
management.call_command(
"flush", "--noinput", database=DEFAULT_DB_ALIAS, verbosity=1
)
logger.info("Database flushed successfully.")
# 2. Load data from the fixture
fixture_name = "fixtures/demo_data.json"
logger.info(f"Loading data from fixture: {fixture_name}...")
management.call_command(
"loaddata", fixture_name, database=DEFAULT_DB_ALIAS, verbosity=1
)
logger.info(f"Data loaded successfully from {fixture_name}.")
logger.info("Daily demo data reset completed.")
except Exception as e:
logger.exception(f"Error during daily demo data reset: {e}")
raise

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-03-09 21:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('currencies', '0013_alter_exchangerateservice_service_type'),
]
operations = [
migrations.AlterModelOptions(
name='currency',
options={'ordering': ['name', 'id'], 'verbose_name': 'Currency', 'verbose_name_plural': 'Currencies'},
),
]

View File

@@ -38,6 +38,7 @@ class Currency(models.Model):
class Meta:
verbose_name = _("Currency")
verbose_name_plural = _("Currencies")
ordering = ["name", "id"]
def clean(self):
super().clean()

View File

@@ -11,9 +11,11 @@ from apps.common.decorators.htmx import only_htmx
from apps.currencies.forms import ExchangeRateForm, ExchangeRateServiceForm
from apps.currencies.models import ExchangeRate, ExchangeRateService
from apps.currencies.tasks import manual_fetch_exchange_rates
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_index(request):
return render(
@@ -24,6 +26,7 @@ def exchange_rates_services_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rates_services_list(request):
services = ExchangeRateService.objects.all()
@@ -37,6 +40,7 @@ def exchange_rates_services_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_add(request):
if request.method == "POST":
@@ -63,6 +67,7 @@ def exchange_rate_service_add(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def exchange_rate_service_edit(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
@@ -91,6 +96,7 @@ def exchange_rate_service_edit(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def exchange_rate_service_delete(request, pk):
service = get_object_or_404(ExchangeRateService, id=pk)
@@ -109,6 +115,7 @@ def exchange_rate_service_delete(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def exchange_rate_service_force_fetch(request):
manual_fetch_exchange_rates.defer()

View File

@@ -41,11 +41,13 @@ from apps.export_app.resources.transactions import (
RecurringTransactionResource,
)
from apps.export_app.resources.users import UserResource
from apps.common.decorators.demo import disabled_on_demo
logger = logging.getLogger()
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET"])
def export_index(request):
@@ -53,6 +55,7 @@ def export_index(request):
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def export_form(request):
@@ -182,6 +185,7 @@ def export_form(request):
@only_htmx
@login_required
@disabled_on_demo
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["GET", "POST"])
def import_form(request):

View File

@@ -13,9 +13,11 @@ from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.services import PresetService
from apps.import_app.tasks import process_import
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def import_presets_list(request):
presets = PresetService.get_all_presets()
@@ -27,6 +29,7 @@ def import_presets_list(request):
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_index(request):
return render(
@@ -37,6 +40,7 @@ def import_profile_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_list(request):
profiles = ImportProfile.objects.all()
@@ -50,6 +54,7 @@ def import_profile_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_add(request):
message = request.POST.get("message", None)
@@ -85,6 +90,7 @@ def import_profile_add(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_profile_edit(request, profile_id):
profile = get_object_or_404(ImportProfile, id=profile_id)
@@ -114,6 +120,7 @@ def import_profile_edit(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -132,6 +139,7 @@ def import_profile_delete(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_runs_list(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -147,6 +155,7 @@ def import_runs_list(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_run_log(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
@@ -160,6 +169,7 @@ def import_run_log(request, profile_id, run_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def import_run_add(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
@@ -202,6 +212,7 @@ def import_run_add(request, profile_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)

View File

@@ -117,13 +117,15 @@ class CategoryForm(forms.Form):
required=False,
label=_("Category"),
empty_label=_("Uncategorized"),
queryset=TransactionCategory.objects.filter(active=True),
queryset=TransactionCategory.objects.all(),
widget=TomSelect(clear_button=True),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["category"].queryset = TransactionCategory.objects.all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True

View File

@@ -40,8 +40,8 @@ def get_currency_exchange_map(date=None) -> Dict[str, dict]:
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
effective_rate=F("rate"),
)
.order_by("from_currency", "to_currency", "date_diff")
.distinct("from_currency", "to_currency")
.order_by("from_currency", "to_currency", "-date_diff")
.distinct()
)
# Initialize the result dictionary

View File

@@ -2,25 +2,38 @@ from collections import OrderedDict, defaultdict
from decimal import Decimal
from dateutil.relativedelta import relativedelta
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField
from django.db.models import Sum, Min, Max, Case, When, F, Value, DecimalField, Q
from django.db.models.functions import TruncMonth
from django.template.defaultfilters import date as date_filter
from django.utils import timezone
from apps.accounts.models import Account
from apps.common.middleware.thread_local import get_current_user
from apps.currencies.models import Currency
from apps.transactions.models import Transaction
def calculate_historical_currency_net_worth(is_paid=True):
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
def calculate_historical_currency_net_worth(queryset):
# Get all currencies and date range in a single query
aggregates = Transaction.objects.aggregate(
aggregates = queryset.aggregate(
min_date=Min("reference_date"),
max_date=Max("reference_date"),
)
currencies = list(Currency.objects.values_list("name", flat=True))
user = get_current_user()
currencies = list(
Currency.objects.filter(
Q(accounts__visibility="public")
| Q(accounts__owner=user)
| Q(accounts__shared_with=user)
| Q(accounts__visibility="private", accounts__owner=None),
accounts__is_archived=False,
accounts__isnull=False,
)
.values_list("name", flat=True)
.distinct()
)
if not aggregates.get("min_date"):
start_date = timezone.localdate(timezone.now())
@@ -34,8 +47,7 @@ def calculate_historical_currency_net_worth(is_paid=True):
# Calculate cumulative balances for each account, currency, and month
cumulative_balances = (
Transaction.objects.filter(**transactions_params)
.annotate(month=TruncMonth("reference_date"))
queryset.annotate(month=TruncMonth("reference_date"))
.values("account__currency__name", "month")
.annotate(
balance=Sum(
@@ -94,15 +106,14 @@ def calculate_historical_currency_net_worth(is_paid=True):
return historical_net_worth
def calculate_historical_account_balance(is_paid=True):
transactions_params = {**{k: v for k, v in [("is_paid", True)] if is_paid}}
def calculate_historical_account_balance(queryset):
# Get all accounts
accounts = Account.objects.filter(
is_archived=False,
)
# Get the date range
date_range = Transaction.objects.filter(**transactions_params).aggregate(
date_range = queryset.aggregate(
min_date=Min("reference_date"), max_date=Max("reference_date")
)
@@ -118,8 +129,7 @@ def calculate_historical_account_balance(is_paid=True):
# Calculate balances for each account and month
balances = (
Transaction.objects.filter(**transactions_params)
.annotate(month=TruncMonth("reference_date"))
queryset.annotate(month=TruncMonth("reference_date"))
.values("account", "month")
.annotate(
balance=Sum(

View File

@@ -38,7 +38,9 @@ def net_worth_current(request):
transactions_queryset=transactions_account_queryset
)
historical_currency_net_worth = calculate_historical_currency_net_worth()
historical_currency_net_worth = calculate_historical_currency_net_worth(
queryset=transactions_currency_queryset
)
labels = (
list(historical_currency_net_worth.keys())
@@ -71,7 +73,9 @@ def net_worth_current(request):
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
historical_account_balance = calculate_historical_account_balance()
historical_account_balance = calculate_historical_account_balance(
queryset=transactions_account_queryset
)
labels = (
list(historical_account_balance.keys()) if historical_account_balance else []
@@ -140,7 +144,7 @@ def net_worth_projected(request):
)
historical_currency_net_worth = calculate_historical_currency_net_worth(
is_paid=False
queryset=transactions_currency_queryset
)
labels = (
@@ -174,7 +178,9 @@ def net_worth_projected(request):
chart_data_currency_json = json.dumps(chart_data_currency, cls=DjangoJSONEncoder)
historical_account_balance = calculate_historical_account_balance(is_paid=False)
historical_account_balance = calculate_historical_account_balance(
queryset=transactions_account_queryset
)
labels = (
list(historical_account_balance.keys()) if historical_account_balance else []

View File

@@ -16,9 +16,11 @@ class TransactionRuleForm(forms.ModelForm):
class Meta:
model = TransactionRule
fields = "__all__"
exclude = ("owner", "shared_with", "visibility")
labels = {
"on_create": _("Run on creation"),
"on_update": _("Run on update"),
"on_delete": _("Run on delete"),
"trigger": _("If..."),
}
widgets = {"description": forms.widgets.TextInput}
@@ -33,7 +35,11 @@ class TransactionRuleForm(forms.ModelForm):
self.helper.layout = Layout(
Switch("active"),
"name",
Row(Column(Switch("on_update")), Column(Switch("on_create"))),
Row(
Column(Switch("on_update")),
Column(Switch("on_create")),
Column(Switch("on_delete")),
),
"description",
"trigger",
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-09 03:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0012_transactionrule_owner_transactionrule_shared_with_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='on_delete',
field=models.BooleanField(default=False),
),
]

View File

@@ -9,6 +9,7 @@ class TransactionRule(SharedObject):
active = models.BooleanField(default=True)
on_update = models.BooleanField(default=False)
on_create = models.BooleanField(default=True)
on_delete = models.BooleanField(default=False)
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger"))

View File

@@ -1,9 +1,11 @@
from django.conf import settings
from django.dispatch import receiver
from apps.transactions.models import (
Transaction,
transaction_created,
transaction_updated,
transaction_deleted,
)
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
@@ -11,7 +13,45 @@ from apps.common.middleware.thread_local import get_current_user
@receiver(transaction_created)
@receiver(transaction_updated)
@receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_deleted:
# Serialize transaction data for processing
transaction_data = {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": True,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
}
check_for_transaction_rules.defer(
transaction_data=transaction_data,
user_id=get_current_user().id,
signal="transaction_deleted",
is_hard_deleted=kwargs.get("hard_delete", not settings.ENABLE_SOFT_DELETE),
)
return
for dca_entry in sender.dca_expense_entries.all():
dca_entry.amount_paid = sender.amount
dca_entry.save()

View File

@@ -1,6 +1,8 @@
import decimal
import logging
from datetime import datetime, date
from decimal import Decimal
from typing import Any
from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta
@@ -26,16 +28,27 @@ logger = logging.getLogger(__name__)
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id: int,
user_id: int,
signal,
instance_id=None,
transaction_data=None,
user_id=None,
signal=None,
is_hard_deleted=False,
):
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
try:
with cachalot_disabled():
instance = Transaction.objects.get(id=instance_id)
# For deleted transactions
if signal == "transaction_deleted" and transaction_data:
# Create a transaction-like object from the serialized data
if is_hard_deleted:
instance = transaction_data
else:
instance = Transaction.deleted_objects.get(id=instance_id)
else:
# Regular transaction processing for creates and updates
instance = Transaction.objects.get(id=instance_id)
functions = {
"relativedelta": relativedelta,
@@ -47,10 +60,11 @@ def check_for_transaction_rules(
"date": date,
}
simple = EvalWithCompoundTypes(
names=_get_names(instance), functions=functions
)
names = _get_names(instance)
simple = EvalWithCompoundTypes(names=names, functions=functions)
# Select rules based on the signal type
if signal == "transaction_created":
rules = TransactionRule.objects.filter(
active=True, on_create=True
@@ -59,39 +73,56 @@ def check_for_transaction_rules(
rules = TransactionRule.objects.filter(
active=True, on_update=True
).order_by("id")
elif signal == "transaction_deleted":
rules = TransactionRule.objects.filter(
active=True, on_delete=True
).order_by("id")
else:
rules = TransactionRule.objects.filter(active=True).order_by("id")
# Process the rules as before
for rule in rules:
if simple.eval(rule.trigger):
for action in rule.transaction_actions.all():
try:
instance = _process_edit_transaction_action(
instance=instance, action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
# else:
# simple.names.update(_get_names(instance))
# instance.save()
# For deleted transactions, we might want to limit what actions can be performed
if signal == "transaction_deleted":
# Process only create/update actions, not edit actions
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id} on deletion",
exc_info=True,
)
else:
# Normal processing for non-deleted transactions
for action in rule.transaction_actions.all():
try:
instance = _process_edit_transaction_action(
instance=instance, action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
simple.names.update(_get_names(instance))
instance.save()
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
)
simple.names.update(_get_names(instance))
if signal != "transaction_deleted":
instance.save()
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
)
except Exception as e:
logger.error(
"Error while executing 'check_for_transaction_rules' task",
@@ -99,40 +130,68 @@ def check_for_transaction_rules(
)
delete_current_user()
raise e
delete_current_user()
def _get_names(instance):
return {
"id": instance.id,
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
"internal_note": instance.internal_note,
"internal_id": instance.internal_id,
}
def _get_names(instance: Transaction | dict):
if isinstance(instance, Transaction):
return {
"id": instance.id,
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
"internal_note": instance.internal_note,
"internal_id": instance.internal_id,
"is_deleted": instance.deleted,
}
else:
return {
"id": instance.get("id"),
"account_name": instance.get("account", (None, None))[1],
"account_id": instance.get("account", (None, None))[0],
"account_group_name": instance.get("account_group", (None, None))[1],
"account_group_id": instance.get("account_group", (None, None))[0],
"is_asset_account": instance.get("is_asset"),
"is_archived_account": instance.get("is_archived"),
"category_name": instance.get("category", (None, None))[1],
"category_id": instance.get("category", (None, None))[0],
"tag_names": [x[1] for x in instance.get("tags", [])],
"tag_ids": [x[0] for x in instance.get("tags", [])],
"entities_names": [x[1] for x in instance.get("entities", [])],
"entities_ids": [x[0] for x in instance.get("entities", [])],
"is_expense": instance.get("type") == Transaction.Type.EXPENSE,
"is_income": instance.get("type") == Transaction.Type.INCOME,
"is_paid": instance.get("is_paid"),
"description": instance.get("description", ""),
"amount": Decimal(instance.get("amount")),
"notes": instance.get("notes", ""),
"date": datetime.fromisoformat(instance.get("date")),
"reference_date": datetime.fromisoformat(instance.get("reference_date")),
"internal_note": instance.get("internal_note", ""),
"internal_id": instance.get("internal_id", ""),
"is_deleted": instance.get("deleted", True),
}
def _process_update_or_create_transaction_action(action, simple_eval):

View File

@@ -18,9 +18,11 @@ from apps.rules.models import (
)
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def rules_index(request):
return render(
@@ -31,6 +33,7 @@ def rules_index(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def rules_list(request):
transaction_rules = TransactionRule.objects.all().order_by("id")
@@ -43,6 +46,7 @@ def rules_list(request):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -65,6 +69,7 @@ def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_add(request, **kwargs):
if request.method == "POST":
@@ -91,6 +96,7 @@ def transaction_rule_add(request, **kwargs):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_edit(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -129,6 +135,7 @@ def transaction_rule_edit(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_view(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -142,6 +149,7 @@ def transaction_rule_view(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_rule_delete(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -166,6 +174,7 @@ def transaction_rule_delete(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_rule_take_ownership(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -187,6 +196,7 @@ def transaction_rule_take_ownership(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_share(request, pk):
obj = get_object_or_404(TransactionRule, id=pk)
@@ -225,6 +235,7 @@ def transaction_rule_share(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -252,6 +263,7 @@ def transaction_rule_action_add(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_rule_action_edit(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(
@@ -289,6 +301,7 @@ def transaction_rule_action_edit(request, transaction_rule_action_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_rule_action_delete(request, transaction_rule_action_id):
transaction_rule_action = get_object_or_404(
@@ -309,6 +322,7 @@ def transaction_rule_action_delete(request, transaction_rule_action_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
@@ -340,6 +354,7 @@ def update_or_create_transaction_rule_action_add(request, transaction_rule_id):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def update_or_create_transaction_rule_action_edit(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)
@@ -374,6 +389,7 @@ def update_or_create_transaction_rule_action_edit(request, pk):
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def update_or_create_transaction_rule_action_delete(request, pk):
linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk)

View File

@@ -334,7 +334,9 @@ class TransferForm(forms.Form):
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
)
description = forms.CharField(max_length=500, label=_("Description"))
description = forms.CharField(
max_length=500, label=_("Description"), required=False
)
notes = forms.CharField(
required=False,
widget=forms.Textarea(
@@ -538,6 +540,8 @@ class InstallmentPlanForm(forms.ModelForm):
"notes",
"installment_start",
"entities",
"add_description_to_transaction",
"add_notes_to_transaction",
]
widgets = {
"account": TomSelect(),
@@ -593,7 +597,9 @@ class InstallmentPlanForm(forms.ModelForm):
css_class="form-row",
),
"description",
Switch("add_description_to_transaction"),
"notes",
Switch("add_notes_to_transaction"),
Row(
Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
Column("installment_start", css_class="form-group col-md-6 mb-0"),
@@ -782,6 +788,7 @@ class RecurringTransactionForm(forms.ModelForm):
"type",
"amount",
"description",
"add_description_to_transaction",
"category",
"tags",
"start_date",
@@ -790,6 +797,7 @@ class RecurringTransactionForm(forms.ModelForm):
"recurrence_type",
"recurrence_interval",
"notes",
"add_notes_to_transaction",
"entities",
]
widgets = {
@@ -850,6 +858,7 @@ class RecurringTransactionForm(forms.ModelForm):
css_class="form-row",
),
"description",
Switch("add_description_to_transaction"),
"amount",
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
@@ -857,6 +866,7 @@ class RecurringTransactionForm(forms.ModelForm):
css_class="form-row",
),
"notes",
Switch("add_notes_to_transaction"),
Row(
Column("start_date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"),

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.7 on 2025-03-09 20:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0040_alter_transaction_unique_together_and_more'),
]
operations = [
migrations.AddField(
model_name='installmentplan',
name='add_description_to_transaction',
field=models.BooleanField(default=True, verbose_name='Add description to transactions'),
),
migrations.AddField(
model_name='installmentplan',
name='add_notes_to_transaction',
field=models.BooleanField(default=True, verbose_name='Add notes to transactions'),
),
migrations.AddField(
model_name='recurringtransaction',
name='add_description_to_transaction',
field=models.BooleanField(default=True, verbose_name='Add description to transactions'),
),
migrations.AddField(
model_name='recurringtransaction',
name='add_notes_to_transaction',
field=models.BooleanField(default=True, verbose_name='Add notes to transactions'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.7 on 2025-03-09 21:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('transactions', '0041_installmentplan_add_description_to_transaction_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='transactioncategory',
options={'ordering': ['name', 'id'], 'verbose_name': 'Transaction Category', 'verbose_name_plural': 'Transaction Categories'},
),
migrations.AlterModelOptions(
name='transactionentity',
options={'ordering': ['name', 'id'], 'verbose_name': 'Entity', 'verbose_name_plural': 'Entities'},
),
migrations.AlterModelOptions(
name='transactiontag',
options={'ordering': ['name', 'id'], 'verbose_name': 'Transaction Tags', 'verbose_name_plural': 'Transaction Tags'},
),
]

View File

@@ -23,6 +23,7 @@ logger = logging.getLogger()
transaction_created = Signal()
transaction_updated = Signal()
transaction_deleted = Signal()
class SoftDeleteQuerySet(models.QuerySet):
@@ -65,8 +66,14 @@ class SoftDeleteQuerySet(models.QuerySet):
def delete(self):
if not settings.ENABLE_SOFT_DELETE:
# If soft deletion is disabled, perform a normal delete
return super().delete()
# Get instances before hard delete
instances = list(self)
# Send signals for each instance before deletion
for instance in instances:
transaction_deleted.send(sender=instance)
# Perform hard delete
result = super().delete()
return result
# Separate the queryset into already deleted and not deleted objects
already_deleted = self.filter(deleted=True)
@@ -74,14 +81,28 @@ class SoftDeleteQuerySet(models.QuerySet):
# Use a transaction to ensure atomicity
with transaction.atomic():
# Get instances for hard delete before they're gone
already_deleted_instances = list(already_deleted)
for instance in already_deleted_instances:
transaction_deleted.send(sender=instance)
# Perform hard delete on already deleted objects
hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
# Get instances for soft delete
instances_to_soft_delete = list(not_deleted)
# Perform soft delete on not deleted objects
soft_deleted_count = not_deleted.update(
deleted=True, deleted_at=timezone.now()
)
# Send signals for soft deleted instances
for instance in instances_to_soft_delete:
instance.deleted = True
instance.deleted_at = timezone.now()
transaction_deleted.send(sender=instance)
# Return a tuple of counts as expected by Django's delete method
return (
hard_deleted_count + soft_deleted_count,
@@ -192,6 +213,7 @@ class TransactionCategory(SharedObject):
verbose_name_plural = _("Transaction Categories")
db_table = "t_categories"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
@@ -215,6 +237,7 @@ class TransactionTag(SharedObject):
verbose_name_plural = _("Transaction Tags")
db_table = "tags"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
@@ -238,6 +261,7 @@ class TransactionEntity(SharedObject):
verbose_name_plural = _("Entities")
db_table = "entities"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
@@ -358,10 +382,14 @@ class Transaction(OwnedObject):
self.deleted = True
self.deleted_at = timezone.now()
self.save()
transaction_deleted.send(sender=self) # Emit signal for soft delete
else:
super().delete(*args, **kwargs)
result = super().delete(*args, **kwargs)
return result
else:
super().delete(*args, **kwargs)
# For hard delete mode
transaction_deleted.send(sender=self) # Emit signal before hard delete
return super().delete(*args, **kwargs)
def hard_delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -468,6 +496,13 @@ class InstallmentPlan(models.Model):
notes = models.TextField(blank=True, verbose_name=_("Notes"))
add_description_to_transaction = models.BooleanField(
default=True, verbose_name=_("Add description to transactions")
)
add_notes_to_transaction = models.BooleanField(
default=True, verbose_name=_("Add notes to transactions")
)
all_objects = models.Manager() # Unfiltered manager
objects = GenericAccountOwnerManager() # Default filtered manager
@@ -532,11 +567,13 @@ class InstallmentPlan(models.Model):
is_paid=False,
reference_date=transaction_reference_date,
amount=self.installment_amount,
description=self.description,
description=(
self.description if self.add_description_to_transaction else ""
),
category=self.category,
installment_plan=self,
installment_id=i,
notes=self.notes,
notes=self.notes if self.add_notes_to_transaction else "",
)
new_transaction.tags.set(self.tags.all())
new_transaction.entities.set(self.entities.all())
@@ -569,9 +606,13 @@ class InstallmentPlan(models.Model):
existing_transaction.type = self.type
existing_transaction.date = transaction_date
existing_transaction.reference_date = transaction_reference_date
existing_transaction.description = self.description
existing_transaction.description = (
self.description if self.add_description_to_transaction else ""
)
existing_transaction.category = self.category
existing_transaction.notes = self.notes
existing_transaction.notes = (
self.notes if self.add_notes_to_transaction else ""
)
if (
not existing_transaction.is_paid
@@ -592,11 +633,13 @@ class InstallmentPlan(models.Model):
is_paid=False,
reference_date=transaction_reference_date,
amount=self.installment_amount,
description=self.description,
description=(
self.description if self.add_description_to_transaction else ""
),
category=self.category,
installment_plan=self,
installment_id=i,
notes=self.notes,
notes=self.notes if self.add_notes_to_transaction else "",
)
new_transaction.tags.set(self.tags.all())
new_transaction.entities.set(self.entities.all())
@@ -672,6 +715,13 @@ class RecurringTransaction(models.Model):
verbose_name=_("Last Generated Reference Date"), null=True, blank=True
)
add_description_to_transaction = models.BooleanField(
default=True, verbose_name=_("Add description to transactions")
)
add_notes_to_transaction = models.BooleanField(
default=True, verbose_name=_("Add notes to transactions")
)
all_objects = models.Manager() # Unfiltered manager
objects = GenericAccountOwnerManager() # Default filtered manager
@@ -718,11 +768,13 @@ class RecurringTransaction(models.Model):
date=date,
reference_date=reference_date.replace(day=1),
amount=self.amount,
description=self.description,
description=(
self.description if self.add_description_to_transaction else ""
),
category=self.category,
is_paid=False,
recurring_transaction=self,
notes=self.notes,
notes=self.notes if self.add_notes_to_transaction else "",
)
if self.tags.exists():
created_transaction.tags.set(self.tags.all())
@@ -796,9 +848,13 @@ class RecurringTransaction(models.Model):
for existing_transaction in unpaid_transactions:
# Update fields based on RecurringTransaction
existing_transaction.amount = self.amount
existing_transaction.description = self.description
existing_transaction.description = (
self.description if self.add_description_to_transaction else ""
)
existing_transaction.category = self.category
existing_transaction.notes = self.notes
existing_transaction.notes = (
self.notes if self.add_notes_to_transaction else ""
)
# Update many-to-many relationships
existing_transaction.tags.set(self.tags.all())

View File

@@ -0,0 +1,34 @@
[
{
"model": "users.user",
"pk": 1,
"fields": {
"password": "pbkdf2_sha256$870000$kUmqeqdenjc2yFS8qbniwS$7qOMXzKG+yFmezdjhptkwuMJlqlZnQHXgAnonWurpBk=",
"last_login": "2025-03-31T03:22:25Z",
"is_superuser": false,
"first_name": "Demo",
"last_name": "User",
"is_staff": false,
"is_active": true,
"date_joined": "2025-03-31T03:21:04Z",
"email": "demo@demo.com",
"groups": [],
"user_permissions": []
}
},
{
"model": "users.usersettings",
"pk": 1,
"fields": {
"user": 1,
"hide_amounts": false,
"mute_sounds": false,
"date_format": "SHORT_DATE_FORMAT",
"datetime_format": "SHORT_DATETIME_FORMAT",
"number_format": "AA",
"language": "auto",
"timezone": "auto",
"start_page": "MONTHLY_OVERVIEW"
}
}
]

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

View File

@@ -5,7 +5,7 @@
{% block title %}{% translate 'Transactions on' %} {{ date|date:"SHORT_DATE_FORMAT" }}{% endblock %}
{% block body %}
<div hx-get="{% url 'calendar_transactions_list' day=date.day month=date.month year=date.year %}" hx-trigger="updated from:window" hx-vals='{"disable_selection": true}' hx-target="closest .offcanvas" class="show-loading" id="transactions-list">
<div hx-get="{% url 'calendar_transactions_list' day=date.day month=date.month year=date.year %}" hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading" id="transactions-list">
{% for transaction in transactions %}
<c-transaction.item :transaction="transaction"></c-transaction.item>
{% empty %}

View File

@@ -5,6 +5,8 @@
<i class="fa-solid fa-user"></i>
</a>
<ul class="dropdown-menu dropdown-menu-start dropdown-menu-lg-end">
<li class="dropdown-item-text">{{ user.email }}</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item"
hx-get="{% url 'user_settings' %}"
hx-target="#generic-offcanvas"

View File

@@ -1,15 +1,30 @@
{% load i18n %}
<script type="text/hyperscript">
behavior htmx_error_handler
on htmx:responseError or htmx:afterRequest[detail.failed] or htmx:sendError queue none
call Swal.fire({title: '{% trans 'Something went wrong loading your data' %}',
text: '{% trans 'Try reloading the page or check the console for more information.' %}',
icon: 'error',
customClass: {
confirmButton: 'btn btn-primary'
},
buttonsStyling: true})
then log event
then halt the event
end
behavior htmx_error_handler
on htmx:responseError or htmx:afterRequest[detail.failed] or htmx:sendError queue none
-- Check if the event detail contains the xhr object and the status is 403
if event.detail.xhr.status == 403 then
call Swal.fire({
title: '{% trans "Access Denied" %}',
text: '{% trans "You do not have permission to perform this action or access this resource." %}',
icon: 'warning',
customClass: {
confirmButton: 'btn btn-warning' -- Optional: different button style
},
buttonsStyling: true
})
else
call Swal.fire({
title: '{% trans "Something went wrong loading your data" %}',
text: '{% trans "Try reloading the page or check the console for more information." %}',
icon: 'error',
customClass: {
confirmButton: 'btn btn-primary'
},
buttonsStyling: true
})
end
then log event
then halt the event
end
</script>

View File

@@ -5,7 +5,7 @@
{% block title %}{% translate 'Installments' %}{% endblock %}
{% block body %}
<div hx-get="{% url 'installment_plan_transactions' installment_plan_id=installment_plan.id %}" hx-trigger="updated from:window" hx-vals='{"disable_selection": true}' hx-target="closest .offcanvas" class="show-loading" id="transactions-list">
<div hx-get="{% url 'installment_plan_transactions' installment_plan_id=installment_plan.id %}" hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading" id="transactions-list">
{% for transaction in transactions %}
<c-transaction.item :transaction="transaction"></c-transaction.item>
{% endfor %}

View File

@@ -1,3 +1,4 @@
{% load settings %}
{% load pwa %}
{% load formats %}
{% load i18n %}
@@ -5,43 +6,53 @@
{% load webpack_loader %}
<!doctype html>
<html lang="en" data-bs-theme="dark">
<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>
<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/head/favicons.html' %}
{% progressive_web_app_meta %}
{% include 'includes/styles.html' %}
{% block extra_styles %}{% endblock %}
{% include 'includes/scripts.html' %}
{% include 'includes/styles.html' %}
{% block extra_styles %}{% endblock %}
{% block extra_js_head %}{% endblock %}
</head>
<body class="font-monospace">
<div _="install hide_amounts
{% include 'includes/scripts.html' %}
{% block extra_js_head %}{% endblock %}
</head>
<body class="font-monospace">
<div _="install hide_amounts
install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %}
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/navbar.html' %}
<div id="content">
{% block content %}{% endblock %}
</div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
{% 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 alert-dismissible fade show 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-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
{% endif %}
{% include 'includes/tools/calculator.html' %}
<div id="content">
{% block content %}{% endblock %}
</div>
{% block extra_js_body %}{% endblock %}
</body>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
</div>
{% include 'includes/tools/calculator.html' %}
{% block extra_js_body %}{% endblock %}
</body>
</html>

View File

@@ -160,7 +160,7 @@
</div>
</div>
{# Filter transactions form#}
<div class="collapse" id="collapse-filter">
<div class="collapse" id="collapse-filter" hx-preserve>
<div class="card card-body">
<form _="on change or submit or search trigger updated on window end
install init_tom_select

View File

@@ -5,7 +5,7 @@
{% block title %}{% translate 'Transactions' %}{% endblock %}
{% block body %}
<div hx-get="{% url 'recurring_transaction_transactions' recurring_transaction_id=recurring_transaction.id %}" hx-trigger="updated from:window" hx-vals='{"disable_selection": true}' hx-target="closest .offcanvas" class="show-loading" id="transactions-list">
<div hx-get="{% url 'recurring_transaction_transactions' recurring_transaction_id=recurring_transaction.id %}" hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading" id="transactions-list">
{% for transaction in transactions %}
<c-transaction.item :transaction="transaction"></c-transaction.item>
{% endfor %}

View File

@@ -1,4 +1,6 @@
{% extends "layouts/base_auth.html" %}
{% load i18n %}
{% load settings %}
{% load crispy_forms_tags %}
{% block title %}Login{% endblock %}
@@ -7,15 +9,26 @@
<div>
<div class="container">
<div class="row vh-100 d-flex justify-content-center align-items-center">
<div class="col-md-6 col-xl-4 col-12">
<div class="card shadow-lg">
<div class="card-body">
<h2 class="card-title text-center mb-4">Login</h2>
{% crispy form %}
</div>
</div>
<div class="col-md-6 col-xl-4 col-12">
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="card shadow mb-3">
<div class="card-body">
<h1 class="h5 card-title text-center mb-4">{% trans "Welcome to WYGIWYH's demo!" %}</h1>
<p>{% trans 'Use the credentials below to login' %}:</p>
<p>{% trans 'E-mail' %}: <span class="badge text-bg-secondary user-select-all">demo@demo.com</span></p>
<p>{% trans 'Password' %}: <span class="badge text-bg-secondary user-select-all">wygiwyhdemo</span></p>
</div>
</div>
{% endif %}
<div class="card shadow-lg">
<div class="card-body">
<h1 class="h2 card-title text-center mb-4">Login</h1>
{% crispy form %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -11,4 +11,6 @@ python manage.py migrate
# Create flag file to signal migrations are complete
touch /tmp/migrations_complete
python manage.py setup_users
exec python manage.py runserver 0.0.0.0:8000

View File

@@ -13,4 +13,6 @@ python manage.py migrate
# Create flag file to signal migrations are complete
touch /tmp/migrations_complete
python manage.py setup_users
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:8000 --timeout 600

View File

@@ -14,7 +14,7 @@ djangorestframework~=3.15.2
drf-spectacular~=0.27.2
django-import-export~=4.3.5
gunicorn==22.0.0
gunicorn==23.0.0
whitenoise[brotli]==6.6.0
watchfiles==0.24.0 # https://github.com/samuelcolvin/watchfiles