Compare commits

...

20 Commits

Author SHA1 Message Date
Herculino Trotta 0ee32724f1 style(toas): move toast to the top of offcanvas 2026-06-06 04:33:23 -03:00
Herculino Trotta 6a19381672 feat(transactions): add attachments 2026-06-06 04:33:06 -03:00
Herculino Trotta 248fec8b4c Merge pull request #541 from eitchtee/weblate
Translations update from Weblate
2026-05-02 16:30:56 -03:00
Weblate b34c0557fa Merge remote-tracking branch 'origin/main' 2026-05-02 19:30:37 +00:00
Herculino Trotta 2af4066aab Merge pull request #542 from eitchtee/fix-procrastinate
fix: stabilize Procrastinate worker database handling
2026-05-02 16:30:33 -03:00
Herculino Trotta d72ff3cdf5 fix(rules): allow category expressions to clear categories 2026-05-02 16:16:27 -03:00
Herculino Trotta 63c69e5c6a test(api): expect unauthorized for anonymous requests 2026-05-02 16:16:08 -03:00
Herculino Trotta 78171183cc test(currencies): avoid test discovery collision 2026-05-02 16:15:48 -03:00
Herculino Trotta 34a2b6bfd4 fix(procrastinate): close Django connections around jobs 2026-05-02 16:15:26 -03:00
masttera 1dc24f855e locale(Russian): update translation
Currently translated at 75.2% (545 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-05-01 07:24:32 +00:00
masttera 1390aff07d locale(Russian): update translation
Currently translated at 74.4% (539 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-05-01 06:24:32 +00:00
Herculino Trotta 8fc11b0acf feat(transactions): hide filter on page load to prevent flashing 2026-05-01 00:07:43 -03:00
Herculino Trotta 9a30a0d3c0 chore: bump versions and other minor things 2026-05-01 00:07:15 -03:00
Herculino Trotta 10eecd09ff fix(frontend): hyperscript not working correctly for offcanvas and modals 2026-04-30 23:16:19 -03:00
Herculino Trotta 2cfb3fb12e fix(frontend): bootstrap-grid-plugin broke 2026-04-30 23:03:42 -03:00
Herculino Trotta 47af8b135b Merge pull request #540 from eitchtee/weblate
Translations update from Weblate
2026-04-30 18:09:27 -03:00
Herculino Trotta 39d0e63375 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (724 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-04-30 02:24:32 +00:00
Herculino Trotta 792154eba2 Merge pull request #539 from eitchtee/dev
fix(tom-select): dropdown covers select field when height increases
2026-04-23 23:30:56 -03:00
Herculino Trotta e627dd50be Merge pull request #537 from eitchtee/dev
fix: deduplication breaks when given m2m fields
2026-04-18 11:48:44 -03:00
Herculino Trotta be24ca014e Merge pull request #536 from eitchtee/dev
chore: deps upgrade
2026-04-18 10:48:18 -03:00
36 changed files with 1007 additions and 77 deletions
+3
View File
@@ -165,3 +165,6 @@ cython_debug/
node_modules/ node_modules/
postgres_data/ postgres_data/
.prod.env .prod.env
# Private local uploads
app/attachments/
+29
View File
@@ -0,0 +1,29 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Docker: Dev",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .env -f docker-compose.dev.yml up --build",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Dev Down"
},
{
"name": "Docker: Dev (no rebuild)",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .env -f docker-compose.dev.yml up",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Dev Down"
},
{
"name": "Docker: Prod",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .prod.env -f docker-compose.prod.yml up --build",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Prod Down"
}
]
}
+119
View File
@@ -0,0 +1,119 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Docker: Dev",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"up",
"--build"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": []
},
{
"label": "Docker: Dev (no rebuild)",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"up"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Dev Refresh Vite Deps",
"type": "shell",
"command": "docker compose --env-file .env -f docker-compose.dev.yml rm -sfv vite; docker compose --env-file .env -f docker-compose.dev.yml up --build",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Dev Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"down"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Prod",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".prod.env",
"-f",
"docker-compose.prod.yml",
"up",
"--build"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Prod Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".prod.env",
"-f",
"docker-compose.prod.yml",
"down"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Django: Runserver localhost:8000",
"type": "shell",
"command": "${command:python.interpreterPath}",
"args": [
"manage.py",
"runserver",
"localhost:8000"
],
"options": {
"cwd": "${workspaceFolder}/app",
"env": {
"PYTHONUNBUFFERED": "1"
}
},
"problemMatcher": []
}
]
}
+8 -7
View File
@@ -311,6 +311,7 @@ LOCALE_PATHS = [BASE_DIR / "locale"]
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static_files" STATIC_ROOT = BASE_DIR / "static_files"
ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments"
STATICFILES_DIRS = [ STATICFILES_DIRS = [
ROOT_DIR / "frontend" / "build", ROOT_DIR / "frontend" / "build",
@@ -440,14 +441,14 @@ REST_FRAMEWORK = {
"apps.api.permissions.NotInDemoMode", "apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions", "rest_framework.permissions.DjangoModelPermissions",
], ],
'DEFAULT_FILTER_BACKENDS': [ "DEFAULT_FILTER_BACKENDS": [
'django_filters.rest_framework.DjangoFilterBackend', "django_filters.rest_framework.DjangoFilterBackend",
'rest_framework.filters.OrderingFilter', "rest_framework.filters.OrderingFilter",
], ],
'DEFAULT_AUTHENTICATION_CLASSES': [ "DEFAULT_AUTHENTICATION_CLASSES": [
'rest_framework.authentication.BasicAuthentication', "rest_framework.authentication.BasicAuthentication",
'rest_framework.authentication.SessionAuthentication', "rest_framework.authentication.SessionAuthentication",
'rest_framework.authentication.TokenAuthentication', "rest_framework.authentication.TokenAuthentication",
], ],
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination", "DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+2 -2
View File
@@ -90,10 +90,10 @@ class AccountBalanceAPITests(TestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_get_balance_unauthenticated(self): def test_get_balance_unauthenticated(self):
"""Test unauthenticated request returns 403""" """Test unauthenticated request returns 401"""
unauthenticated_client = APIClient() unauthenticated_client = APIClient()
response = unauthenticated_client.get( response = unauthenticated_client.get(
f"/api/accounts/{self.account.id}/balance/" f"/api/accounts/{self.account.id}/balance/"
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+6 -6
View File
@@ -159,7 +159,7 @@ column_mapping:
self.assertIn("import_run_id", response.data) self.assertIn("import_run_id", response.data)
def test_unauthenticated_request(self): def test_unauthenticated_request(self):
"""Test unauthenticated request returns 403""" """Test unauthenticated request returns 401"""
unauthenticated_client = APIClient() unauthenticated_client = APIClient()
csv_content = b"date,description,amount\n2025-01-01,Test,100" csv_content = b"date,description,amount\n2025-01-01,Test,100"
@@ -173,7 +173,7 @@ column_mapping:
format="multipart", format="multipart",
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings( @override_settings(
@@ -266,11 +266,11 @@ column_mapping:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_profiles_unauthenticated(self): def test_profiles_unauthenticated(self):
"""Test unauthenticated request returns 403""" """Test unauthenticated request returns 401"""
unauthenticated_client = APIClient() unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/profiles/") response = unauthenticated_client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings( @override_settings(
@@ -397,8 +397,8 @@ column_mapping:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_runs_unauthenticated(self): def test_runs_unauthenticated(self):
"""Test unauthenticated request returns 403""" """Test unauthenticated request returns 401"""
unauthenticated_client = APIClient() unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/runs/") response = unauthenticated_client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+42 -1
View File
@@ -1,6 +1,47 @@
import functools
import inspect
import procrastinate import procrastinate
from django.db import close_old_connections
_CONNECTION_CLEANUP_WRAPPED = "_wygiwyh_connection_cleanup_wrapped"
def _wrap_task_with_django_connection_cleanup(task):
if getattr(task.func, _CONNECTION_CLEANUP_WRAPPED, False):
return
func = task.func
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapped(*args, **kwargs):
close_old_connections()
try:
return await func(*args, **kwargs)
finally:
close_old_connections()
wrapped = async_wrapped
else:
@functools.wraps(func)
def sync_wrapped(*args, **kwargs):
close_old_connections()
try:
return func(*args, **kwargs)
finally:
close_old_connections()
wrapped = sync_wrapped
setattr(wrapped, _CONNECTION_CLEANUP_WRAPPED, True)
task.func = wrapped
def on_app_ready(app: procrastinate.App): def on_app_ready(app: procrastinate.App):
"""This function is ran upon procrastinate initialization.""" """This function is ran upon procrastinate initialization."""
... for task in set(app.tasks.values()):
_wrap_task_with_django_connection_cleanup(task)
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,89 @@
from unittest.mock import patch
import procrastinate
from django.db import connection
from django.test import SimpleTestCase, TransactionTestCase
from procrastinate.testing import InMemoryConnector
from apps.common.procrastinate import on_app_ready
def make_app_with_task(func):
app = procrastinate.App(connector=InMemoryConnector())
task = app.task(name="sample_task")(func)
return app, task
class ProcrastinateConnectionCleanupTests(SimpleTestCase):
def test_app_ready_closes_old_connections_around_sync_tasks(self):
calls = []
def sample_task(value):
calls.append(("task", value))
return value * 2
app, task = make_app_with_task(sample_task)
with patch(
"apps.common.procrastinate.close_old_connections",
create=True,
side_effect=lambda: calls.append(("cleanup", None)),
):
on_app_ready(app)
result = task.func(3)
self.assertEqual(result, 6)
self.assertEqual(
calls,
[
("cleanup", None),
("task", 3),
("cleanup", None),
],
)
def test_app_ready_closes_old_connections_when_sync_task_raises(self):
calls = []
def sample_task():
calls.append(("task", None))
raise RuntimeError("boom")
app, task = make_app_with_task(sample_task)
with patch(
"apps.common.procrastinate.close_old_connections",
create=True,
side_effect=lambda: calls.append(("cleanup", None)),
):
on_app_ready(app)
with self.assertRaises(RuntimeError):
task.func()
self.assertEqual(
calls,
[
("cleanup", None),
("task", None),
("cleanup", None),
],
)
class ProcrastinateConnectionRecoveryTests(TransactionTestCase):
def test_wrapped_task_recovers_from_closed_django_connection(self):
def sample_task():
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return cursor.fetchone()[0]
app, task = make_app_with_task(sample_task)
on_app_ready(app)
connection.ensure_connection()
connection.connection.close()
self.assertEqual(task.func(), 1)
+6 -2
View File
@@ -365,7 +365,9 @@ def check_for_transaction_rules(
if processed_action.set_category: if processed_action.set_category:
value = simple.eval(processed_action.set_category) value = simple.eval(processed_action.set_category)
if isinstance(value, int): if value is None:
transaction.category = None
elif isinstance(value, int):
transaction.category = TransactionCategory.objects.get(id=value) transaction.category = TransactionCategory.objects.get(id=value)
else: else:
transaction.category = TransactionCategory.objects.get(name=value) transaction.category = TransactionCategory.objects.get(name=value)
@@ -458,7 +460,9 @@ def check_for_transaction_rules(
transaction.account = account transaction.account = account
elif field == TransactionRuleAction.Field.category: elif field == TransactionRuleAction.Field.category:
if isinstance(new_value, int): if new_value is None:
transaction.category = None
elif isinstance(new_value, int):
category = TransactionCategory.objects.get(id=new_value) category = TransactionCategory.objects.get(id=new_value)
transaction.category = category transaction.category = category
elif isinstance(new_value, str): elif isinstance(new_value, str):
+1
View File
@@ -0,0 +1 @@
+82
View File
@@ -0,0 +1,82 @@
from datetime import date
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.tasks import check_for_transaction_rules
from apps.transactions.models import Transaction
def run_check_for_transaction_rules_without_worker_wrapper(**kwargs):
task_func = check_for_transaction_rules.func
task_func = getattr(task_func, "__wrapped__", task_func)
return task_func(**kwargs)
class CheckForTransactionRulesTests(TransactionTestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
email="rules@example.com",
password="testpass123",
)
self.currency = Currency.objects.create(
code="USD",
name="US Dollar",
decimal_places=2,
)
self.account = Account.objects.create(
name="Main Account",
currency=self.currency,
owner=self.user,
)
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_update_or_create_action_can_clear_category_from_none_expression(
self, mock_defer
):
source_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
amount=Decimal("10.00"),
date=date(2026, 5, 4),
reference_date=date(2026, 5, 1),
description="Source without category",
category=None,
owner=self.user,
)
rule = TransactionRule.objects.create(
active=True,
on_create=False,
on_update=True,
name="Copy transaction",
trigger="True",
owner=self.user,
)
UpdateOrCreateTransactionRuleAction.objects.create(
rule=rule,
set_account="account_id",
set_type="'EX'",
set_date="date",
set_reference_date="reference_date",
set_amount="amount",
set_description="'Generated transaction'",
set_category="category_name",
)
run_check_for_transaction_rules_without_worker_wrapper(
instance_id=source_transaction.id,
user_id=self.user.id,
signal="transaction_updated",
)
generated_transaction = Transaction.objects.get(
description="Generated transaction"
)
self.assertIsNone(generated_transaction.category)
+52
View File
@@ -14,6 +14,7 @@ from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import ( from apps.transactions.models import (
InstallmentPlan, InstallmentPlan,
TransactionAttachment,
QuickTransaction, QuickTransaction,
RecurringTransaction, RecurringTransaction,
Transaction, Transaction,
@@ -36,6 +37,22 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
widget = MultipleFileInput
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(file, initial) for file in data]
if data:
return [single_file_clean(data, initial)]
return []
class TransactionForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name", create_field="name",
@@ -247,6 +264,41 @@ class TransactionForm(forms.ModelForm):
return instance return instance
class TransactionAttachmentForm(forms.Form):
attachments = MultipleFileField(
required=True,
label=_("Attachments"),
help_text=_("Files are private and only visible to users with access to this transaction."),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"attachments",
FormActions(
NoClassSubmit("submit", _("Upload"), css_class="btn btn-primary"),
),
)
def save(self, transaction, uploaded_by):
created = []
for attachment in self.cleaned_data.get("attachments") or []:
created.append(
TransactionAttachment.objects.create(
transaction=transaction,
file=attachment,
original_name=attachment.name,
content_type=getattr(attachment, "content_type", ""),
size=attachment.size,
uploaded_by=uploaded_by,
)
)
return created
class QuickTransactionForm(forms.ModelForm): class QuickTransactionForm(forms.ModelForm):
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name", create_field="name",
@@ -0,0 +1,38 @@
# Generated by Django 5.2.13 on 2026-06-06 02:34
import apps.transactions.models
import apps.transactions.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0048_recurringtransaction_keep_at_most'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TransactionAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(storage=apps.transactions.storage.PrivateMediaStorage(), upload_to=apps.transactions.models.transaction_attachment_path, verbose_name='File')),
('original_name', models.CharField(max_length=255, verbose_name='Original Name')),
('content_type', models.CharField(blank=True, max_length=255, verbose_name='Content Type')),
('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')),
('created_at', models.DateTimeField(auto_now_add=True)),
('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='transactions.transaction', verbose_name='Transaction')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Transaction Attachment',
'verbose_name_plural': 'Transaction Attachments',
'db_table': 'transaction_attachments',
'ordering': ['-created_at', 'original_name'],
},
),
]
+64 -1
View File
@@ -1,6 +1,8 @@
import decimal import decimal
import logging import logging
import uuid
from copy import deepcopy from copy import deepcopy
from pathlib import Path
from apps.common.fields.month_year import MonthYearModelField from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal from apps.common.functions.decimals import truncate_decimal
@@ -13,13 +15,15 @@ from apps.common.models import (
) )
from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number
from apps.currencies.utils.convert import convert from apps.currencies.utils.convert import convert
from apps.transactions.storage import PrivateMediaStorage
from apps.transactions.validators import validate_decimal_places, validate_non_negative from apps.transactions.validators import validate_decimal_places, validate_non_negative
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.dispatch import Signal from django.db.models.signals import post_delete
from django.dispatch import Signal, receiver
from django.template.defaultfilters import date from django.template.defaultfilters import date
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -32,6 +36,11 @@ transaction_updated = Signal()
transaction_deleted = Signal() transaction_deleted = Signal()
def transaction_attachment_path(instance, filename):
extension = Path(filename).suffix
return f"transaction_attachments/{instance.transaction_id}/{instance.id}{extension}"
class SoftDeleteQuerySet(models.QuerySet): class SoftDeleteQuerySet(models.QuerySet):
@staticmethod @staticmethod
def _emit_signals(instances, created=False, old_data=None): def _emit_signals(instances, created=False, old_data=None):
@@ -526,6 +535,60 @@ class Transaction(OwnedObject):
return new_obj return new_obj
class TransactionAttachment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
transaction = models.ForeignKey(
Transaction,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name=_("Transaction"),
)
file = models.FileField(
upload_to=transaction_attachment_path,
storage=PrivateMediaStorage(),
verbose_name=_("File"),
)
original_name = models.CharField(max_length=255, verbose_name=_("Original Name"))
content_type = models.CharField(
max_length=255, blank=True, verbose_name=_("Content Type")
)
size = models.PositiveBigIntegerField(default=0, verbose_name=_("Size"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="transaction_attachments",
verbose_name=_("Uploaded By"),
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Transaction Attachment")
verbose_name_plural = _("Transaction Attachments")
db_table = "transaction_attachments"
ordering = ["-created_at", "original_name"]
def save(self, *args, **kwargs):
if self.file:
if not self.original_name:
self.original_name = Path(self.file.name).name
if not self.size:
self.size = self.file.size
if not self.content_type:
self.content_type = getattr(self.file.file, "content_type", "")
super().save(*args, **kwargs)
def __str__(self):
return self.original_name
@receiver(post_delete, sender=TransactionAttachment)
def delete_transaction_attachment_file(sender, instance, **kwargs):
if not instance.file.name:
return
storage = instance.file.storage
if storage.exists(instance.file.name):
storage.delete(instance.file.name)
class InstallmentPlan(models.Model): class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices): class Recurrence(models.TextChoices):
+9
View File
@@ -0,0 +1,9 @@
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class PrivateMediaStorage(FileSystemStorage):
def __init__(self, *args, **kwargs):
kwargs.setdefault("location", settings.ATTACHMENT_MEDIA_ROOT)
kwargs.setdefault("base_url", None)
super().__init__(*args, **kwargs)
@@ -0,0 +1,219 @@
import shutil
import tempfile
from datetime import date
from decimal import Decimal
from pathlib import Path
from apps.accounts.models import Account
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.transactions.models import Transaction, TransactionAttachment
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionAttachmentTests(TestCase):
def setUp(self):
self.attachment_media_root = tempfile.mkdtemp()
self.override_private_media = override_settings(
ATTACHMENT_MEDIA_ROOT=self.attachment_media_root
)
self.override_private_media.enable()
self.addCleanup(self.override_private_media.disable)
self.addCleanup(shutil.rmtree, self.attachment_media_root, ignore_errors=True)
self.attachment_storage = TransactionAttachment._meta.get_field("file").storage
self.original_storage_location = self.attachment_storage._location
self.attachment_storage._location = self.attachment_media_root
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
self.addCleanup(self.restore_attachment_storage)
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.user1_account = Account.all_objects.create(
name="User1 Account", currency=self.currency, owner=self.user1
)
self.user2_account = Account.all_objects.create(
name="User2 Account", currency=self.currency, owner=self.user2
)
self.transaction = Transaction.userless_all_objects.create(
account=self.user1_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("12.34"),
is_paid=True,
date=date(2026, 6, 5),
description="Receipt transaction",
owner=self.user1,
)
self.other_transaction = Transaction.userless_all_objects.create(
account=self.user2_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("56.78"),
is_paid=True,
date=date(2026, 6, 5),
description="Other receipt transaction",
owner=self.user2,
)
def restore_attachment_storage(self):
self.attachment_storage._location = self.original_storage_location
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
def test_attachment_uses_uuid_and_preserves_original_download_name(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile(
"receipt June.pdf", b"receipt bytes", content_type="application/pdf"
),
uploaded_by=self.user1,
)
self.assertEqual(attachment.original_name, "receipt June.pdf")
self.assertNotIn("receipt June.pdf", attachment.file.name)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(b"".join(response.streaming_content), b"receipt bytes")
self.assertIn('filename="receipt June.pdf"', response["Content-Disposition"])
def test_user_without_transaction_access_cannot_download_attachment(self):
attachment = TransactionAttachment.objects.create(
transaction=self.other_transaction,
file=SimpleUploadedFile("private.txt", b"private"),
uploaded_by=self.user2,
)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 404)
def test_attachment_button_lives_in_transaction_hover_toolbar(self):
template = Path("templates/cotton/transaction/item.html").read_text()
before_toolbar, toolbar = template.split("{# Item actions#}", 1)
self.assertNotIn("transaction_attachments", before_toolbar)
self.assertLess(
toolbar.index("transaction_edit"),
toolbar.index("transaction_attachments"),
)
self.assertLess(
toolbar.index("transaction_attachments"),
toolbar.index("transaction_delete"),
)
def test_transaction_edit_form_does_not_include_attachment_upload(self):
self.client.force_login(self.user1)
response = self.client.get(
reverse("transaction_edit", kwargs={"transaction_id": self.transaction.id}),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "multipart/form-data")
self.assertNotContains(response, 'type="file"')
def test_attachment_management_uploads_multiple_attachments(self):
self.client.force_login(self.user1)
response = self.client.post(
reverse(
"transaction_attachments",
kwargs={"transaction_id": self.transaction.id},
),
{
"attachments": [
SimpleUploadedFile("first.txt", b"first"),
SimpleUploadedFile("second.txt", b"second"),
],
},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "first.txt")
self.assertContains(response, "second.txt")
self.assertEqual(self.transaction.attachments.count(), 2)
def test_attachment_delete_returns_refreshed_attachment_list(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("delete-me.txt", b"delete"),
uploaded_by=self.user1,
)
self.client.force_login(self.user1)
response = self.client.delete(
reverse(
"transaction_attachment_delete",
kwargs={"attachment_id": attachment.id},
),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "delete-me.txt")
self.assertContains(response, "No attachments yet")
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
def test_hard_deleting_transaction_deletes_attachment_files(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("hard-delete.txt", b"delete with transaction"),
uploaded_by=self.user1,
)
file_path = Path(attachment.file.path)
self.assertTrue(file_path.exists())
write_current_user(self.user1)
self.addCleanup(delete_current_user)
self.transaction.delete()
self.assertTrue(file_path.exists())
self.assertTrue(TransactionAttachment.objects.filter(id=attachment.id).exists())
self.transaction.delete()
self.assertFalse(file_path.exists())
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
+20
View File
@@ -81,6 +81,26 @@ urlpatterns = [
views.transaction_move_to_today, views.transaction_move_to_today,
name="transaction_move_to_today", name="transaction_move_to_today",
), ),
path(
"transaction/<int:transaction_id>/attachments/",
views.transaction_attachments,
name="transaction_attachments",
),
path(
"transaction/<int:transaction_id>/attachments/list/",
views.transaction_attachments_list,
name="transaction_attachments_list",
),
path(
"transaction/attachments/<uuid:attachment_id>/download/",
views.transaction_attachment_download,
name="transaction_attachment_download",
),
path(
"transaction/attachments/<uuid:attachment_id>/delete/",
views.transaction_attachment_delete,
name="transaction_attachment_delete",
),
path( path(
"transaction/<int:transaction_id>/delete/", "transaction/<int:transaction_id>/delete/",
views.transaction_delete, views.transaction_delete,
+102 -14
View File
@@ -1,32 +1,120 @@
import datetime import datetime
from copy import deepcopy from copy import deepcopy
from dateutil.relativedelta import relativedelta from apps.common.decorators.demo import disabled_on_demo
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, When, Case, Value, IntegerField
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.rules.signals import transaction_created, transaction_updated from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import ( from apps.transactions.forms import (
BulkEditTransactionForm,
TransactionAttachmentForm,
TransactionForm, TransactionForm,
TransferForm, TransferForm,
BulkEditTransactionForm,
) )
from apps.transactions.models import Transaction from apps.transactions.models import Transaction, TransactionAttachment
from apps.transactions.utils.calculations import ( from apps.transactions.utils.calculations import (
calculate_currency_totals,
calculate_account_totals, calculate_account_totals,
calculate_currency_totals,
calculate_percentage_distribution, calculate_percentage_distribution,
) )
from apps.transactions.utils.default_ordering import default_order from apps.transactions.utils.default_ordering import default_order
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from django.views.decorators.http import require_http_methods
def _get_accessible_transaction_or_404(transaction_id):
return get_object_or_404(Transaction.objects, id=transaction_id)
def _get_accessible_attachment_or_404(attachment_id):
attachment = get_object_or_404(
TransactionAttachment.objects.select_related("transaction"),
id=attachment_id,
)
if not Transaction.objects.filter(id=attachment.transaction_id).exists():
raise Http404()
return attachment
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_attachments(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
if request.method == "POST":
form = TransactionAttachmentForm(request.POST, request.FILES)
if form.is_valid():
form.save(transaction=transaction, uploaded_by=request.user)
messages.success(request, _("Attachment uploaded successfully"))
form = TransactionAttachmentForm()
else:
form = TransactionAttachmentForm()
response = render(
request,
"transactions/fragments/attachments_manage.html",
{"form": form, "transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachments_list(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
return render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachment_download(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
return FileResponse(
attachment.file.open("rb"),
as_attachment=False,
filename=attachment.original_name,
content_type=attachment.content_type or "application/octet-stream",
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_attachment_delete(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
transaction = attachment.transaction
attachment.file.delete(save=False)
attachment.delete()
messages.success(request, _("Attachment deleted successfully"))
response = render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx @only_htmx
+3 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-16 02:24+0000\n" "POT-Creation-Date: 2026-02-16 02:24+0000\n"
"PO-Revision-Date: 2026-03-02 22:30+0000\n" "PO-Revision-Date: 2026-04-30 02:24+0000\n"
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n" "Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
"Language-Team: Portuguese (Brazil) <https://translations.herculino.com/" "Language-Team: Portuguese (Brazil) <https://translations.herculino.com/"
"projects/wygiwyh/app/pt_BR/>\n" "projects/wygiwyh/app/pt_BR/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n" "Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.16.1\n" "X-Generator: Weblate 5.17\n"
#: apps/accounts/forms.py:24 #: apps/accounts/forms.py:24
msgid "Group name" msgid "Group name"
@@ -3059,7 +3059,7 @@ msgstr "Abr"
#: templates/insights/fragments/month_by_month.html:98 #: templates/insights/fragments/month_by_month.html:98
msgid "May" msgid "May"
msgstr "Mai" msgstr "Maio"
#: templates/insights/fragments/month_by_month.html:99 #: templates/insights/fragments/month_by_month.html:99
msgid "Jun" msgid "Jun"
+20 -20
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-16 02:24+0000\n" "POT-Creation-Date: 2026-02-16 02:24+0000\n"
"PO-Revision-Date: 2026-03-31 13:24+0000\n" "PO-Revision-Date: 2026-05-01 07:24+0000\n"
"Last-Translator: masttera <mail.masttera@gmail.com>\n" "Last-Translator: masttera <mail.masttera@gmail.com>\n"
"Language-Team: Russian <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: Russian <https://translations.herculino.com/projects/wygiwyh/"
"app/ru/>\n" "app/ru/>\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.16.2\n" "X-Generator: Weblate 5.17\n"
#: apps/accounts/forms.py:24 #: apps/accounts/forms.py:24
msgid "Group name" msgid "Group name"
@@ -1513,7 +1513,7 @@ msgstr ""
#: templates/insights/fragments/category_overview/index.html:87 #: templates/insights/fragments/category_overview/index.html:87
#: templates/monthly_overview/fragments/monthly_summary.html:41 #: templates/monthly_overview/fragments/monthly_summary.html:41
msgid "Income" msgid "Income"
msgstr "" msgstr "Доход"
#: apps/transactions/models.py:289 apps/transactions/models.py:988 #: apps/transactions/models.py:289 apps/transactions/models.py:988
#: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:46
@@ -2178,7 +2178,7 @@ msgstr "Вы уверены?"
#: templates/rules/fragments/transaction_rule/view.html:97 #: templates/rules/fragments/transaction_rule/view.html:97
#: templates/tags/fragments/table.html:56 #: templates/tags/fragments/table.html:56
msgid "You won't be able to revert this!" msgid "You won't be able to revert this!"
msgstr "" msgstr "Вы не сможете отменить это!"
#: templates/account_groups/fragments/list.html:60 #: templates/account_groups/fragments/list.html:60
#: templates/accounts/fragments/list.html:77 #: templates/accounts/fragments/list.html:77
@@ -2237,7 +2237,7 @@ msgstr "Сверка балансов"
#: templates/accounts/fragments/add.html:5 #: templates/accounts/fragments/add.html:5
msgid "Add account" msgid "Add account"
msgstr "" msgstr "Добавить счёт"
#: templates/accounts/fragments/edit.html:5 #: templates/accounts/fragments/edit.html:5
msgid "Edit account" msgid "Edit account"
@@ -2688,7 +2688,7 @@ msgstr ""
#: templates/exchange_rates_services/fragments/list.html:17 #: templates/exchange_rates_services/fragments/list.html:17
msgid "Fetch all" msgid "Fetch all"
msgstr "" msgstr "Отметить все"
#: templates/exchange_rates_services/fragments/list.html:29 #: templates/exchange_rates_services/fragments/list.html:29
msgid "Service" msgid "Service"
@@ -2719,7 +2719,7 @@ msgstr ""
#: templates/exchange_rates_services/fragments/list.html:77 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr "Службы не настроены"
#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:205 #: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:205
msgid "Export and Restore" msgid "Export and Restore"
@@ -2751,7 +2751,7 @@ msgstr ""
#: templates/import_app/fragments/profiles/list.html:80 #: templates/import_app/fragments/profiles/list.html:80
msgid "No import profiles" msgid "No import profiles"
msgstr "" msgstr "Нет профилей для импорта"
#: templates/import_app/fragments/profiles/list_presets.html:5 #: templates/import_app/fragments/profiles/list_presets.html:5
msgid "Import Presets" msgid "Import Presets"
@@ -2877,11 +2877,11 @@ msgstr ""
#: templates/includes/sidebar.html:69 templates/insights/pages/index.html:5 #: templates/includes/sidebar.html:69 templates/insights/pages/index.html:5
msgid "Insights" msgid "Insights"
msgstr "" msgstr "Аналитика"
#: templates/includes/sidebar.html:75 #: templates/includes/sidebar.html:75
msgid "Net Worth" msgid "Net Worth"
msgstr "" msgstr "Чистый капитал"
#: templates/includes/sidebar.html:91 #: templates/includes/sidebar.html:91
msgid "Trash Can" msgid "Trash Can"
@@ -2899,7 +2899,7 @@ msgstr "Трекер средней стоимости доллара"
#: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:4
#: templates/mini_tools/unit_price_calculator.html:9 #: templates/mini_tools/unit_price_calculator.html:9
msgid "Unit Price Calculator" msgid "Unit Price Calculator"
msgstr "" msgstr "Калькулятор цены за единицу"
#: templates/includes/sidebar.html:130 #: templates/includes/sidebar.html:130
#: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:7
@@ -2909,7 +2909,7 @@ msgstr "Конвертер валют"
#: templates/includes/sidebar.html:139 #: templates/includes/sidebar.html:139
msgid "Management" msgid "Management"
msgstr "" msgstr "Управление"
#: templates/includes/sidebar.html:190 #: templates/includes/sidebar.html:190
msgid "Automation" msgid "Automation"
@@ -3025,7 +3025,7 @@ msgstr "Всё отлично!"
#: templates/insights/fragments/late_transactions.html:16 #: templates/insights/fragments/late_transactions.html:16
msgid "No late transactions" msgid "No late transactions"
msgstr "" msgstr "Нет просроченных транзакций"
#: templates/insights/fragments/latest_transactions.html:14 #: templates/insights/fragments/latest_transactions.html:14
msgid "No recent transactions" msgid "No recent transactions"
@@ -3123,31 +3123,31 @@ msgstr "Диапазон дат"
#: templates/insights/pages/index.html:79 #: templates/insights/pages/index.html:79
msgid "Account Flow" msgid "Account Flow"
msgstr "" msgstr "Движение по счету"
#: templates/insights/pages/index.html:84 #: templates/insights/pages/index.html:84
msgid "Currency Flow" msgid "Currency Flow"
msgstr "" msgstr "Движение валюты"
#: templates/insights/pages/index.html:89 #: templates/insights/pages/index.html:89
msgid "Category Explorer" msgid "Category Explorer"
msgstr "" msgstr "Обзор по категориям"
#: templates/insights/pages/index.html:94 #: templates/insights/pages/index.html:94
msgid "Categories Overview" msgid "Categories Overview"
msgstr "" msgstr "Все категории"
#: templates/insights/pages/index.html:112 #: templates/insights/pages/index.html:112
msgid "Late Transactions" msgid "Late Transactions"
msgstr "" msgstr "Просроченные транзакции"
#: templates/insights/pages/index.html:117 #: templates/insights/pages/index.html:117
msgid "Latest Transactions" msgid "Latest Transactions"
msgstr "" msgstr "Последние транзакции"
#: templates/insights/pages/index.html:122 #: templates/insights/pages/index.html:122
msgid "Emergency Fund" msgid "Emergency Fund"
msgstr "" msgstr "Резервный фонд"
#: templates/insights/pages/index.html:127 #: templates/insights/pages/index.html:127
msgid "Year by Year" msgid "Year by Year"
@@ -147,6 +147,12 @@
hx-target="#generic-offcanvas" hx-swap="innerHTML" hx-target="#generic-offcanvas" hx-swap="innerHTML"
data-tippy-content="{% translate "Edit" %}"> data-tippy-content="{% translate "Edit" %}">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-soft btn-sm transaction-action gap-1"
role="button"
hx-get="{% url 'transaction_attachments' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML"
data-tippy-content="{% translate "Attachments" %}">
<i class="fa-solid fa-paperclip fa-fw"></i><span>{{ transaction.attachments.count }}</span></a>
<a class="btn btn-error btn-soft btn-sm transaction-action" <a class="btn btn-error btn-soft btn-sm transaction-action"
role="button" role="button"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}" hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
@@ -268,7 +268,7 @@
</div> </div>
{# Filter transactions form #} {# Filter transactions form #}
<div class="z-1" x-show="filterOpen" x-collapse> <div class="z-1" x-show="filterOpen" x-collapse x-cloak>
<div class="card card-body bg-base-200 mt-2"> <div class="card card-body bg-base-200 mt-2">
<div class="text-right"> <div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit" <button class="btn btn-outline btn-error btn-sm w-fit"
@@ -0,0 +1,32 @@
{% load i18n %}
<div id="transaction-attachments-list" class="mt-4">
<h3 class="font-semibold mb-2">{% translate 'Attachments' %}</h3>
{% if transaction.attachments.exists %}
<ul class="flex flex-col gap-2">
{% for attachment in transaction.attachments.all %}
<li class="flex items-center justify-between gap-3 rounded-box border border-base-content/20 p-3">
<a class="link link-primary truncate"
target="_blank"
href="{% url 'transaction_attachment_download' attachment_id=attachment.id %}">
{{ attachment.original_name }}
</a>
<button class="btn btn-error btn-sm btn-soft"
type="button"
hx-delete="{% url 'transaction_attachment_delete' attachment_id=attachment.id %}"
hx-trigger="confirmed"
hx-target="#transaction-attachments-list"
hx-swap="outerHTML"
data-title="{% translate 'Delete this attachment?' %}"
data-text="{% translate 'This file will be removed from the transaction.' %}"
data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-base-content/60">{% translate 'No attachments yet' %}</p>
{% endif %}
</div>
@@ -0,0 +1,17 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Transaction attachments' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_attachments' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
enctype="multipart/form-data"
novalidate>
{% crispy form %}
</form>
{% include 'transactions/fragments/attachments.html' %}
{% endblock %}
@@ -218,7 +218,7 @@
</div> </div>
{# Filter transactions form #} {# Filter transactions form #}
<div class="z-1" x-show="filterOpen" x-collapse> <div class="z-1" x-show="filterOpen" x-collapse x-cloak>
<div class="card card-body bg-base-200 mt-2"> <div class="card card-body bg-base-200 mt-2">
<div class="text-right"> <div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit" <button class="btn btn-outline btn-error btn-sm w-fit"
+2
View File
@@ -1,6 +1,7 @@
volumes: volumes:
wygiwyh_dev_postgres_data: {} wygiwyh_dev_postgres_data: {}
wygiwyh_temp: wygiwyh_temp:
wygiwyh_attachments:
services: services:
@@ -14,6 +15,7 @@ services:
- ./app/:/usr/src/app/:z - ./app/:/usr/src/app/:z
- ./frontend/:/usr/src/frontend:z - ./frontend/:/usr/src/frontend:z
- wygiwyh_temp:/usr/src/app/temp/ - wygiwyh_temp:/usr/src/app/temp/
- wygiwyh_attachments:/usr/src/app/attachments/
ports: ports:
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}" - "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file: env_file:
+2
View File
@@ -6,6 +6,8 @@ services:
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}" - "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file: env_file:
- .env - .env
volumes:
- ./media:/usr/src/app/attachments/
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
+2 -2
View File
@@ -2,9 +2,9 @@ FROM node:lts-alpine
WORKDIR /usr/src/frontend WORKDIR /usr/src/frontend
COPY ./frontend/package.json . COPY ./frontend/package.json ./frontend/package-lock.json ./
RUN npm install --verbose && npm cache clean --force RUN npm ci --verbose && npm cache clean --force
ENV PATH ./node_modules/.bin/:$PATH ENV PATH ./node_modules/.bin/:$PATH
+14 -11
View File
@@ -23,7 +23,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.0", "chartjs-chart-sankey": "^0.14.0",
"daisyui": "^5.5.5", "daisyui": "5.5.19",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"hyperscript.org": "^0.9.14", "hyperscript.org": "^0.9.14",
"mathjs": "^15.2.0", "mathjs": "^15.2.0",
@@ -1667,12 +1667,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.25", "version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
"integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/bootstrap": { "node_modules/bootstrap": {
@@ -1743,9 +1746,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001754", "version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1816,9 +1819,9 @@
} }
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.5.5", "version": "5.5.19",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.5.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz",
"integrity": "sha512-ekvI93ZkWIJoCOtDl0D2QMxnWvTejk9V5nWBqRv+7t0xjiBXqAK5U6o6JE2RPvlIC3EqwNyUoIZSdHX9MZK3nw==", "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1" "url": "https://github.com/saadeghi/daisyui?sponsor=1"
+1 -1
View File
@@ -30,7 +30,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.0", "chartjs-chart-sankey": "^0.14.0",
"daisyui": "^5.5.5", "daisyui": "5.5.19",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"hyperscript.org": "^0.9.14", "hyperscript.org": "^0.9.14",
"mathjs": "^15.2.0", "mathjs": "^15.2.0",
+5 -3
View File
@@ -1,4 +1,4 @@
import 'hyperscript.org'; import _hyperscript from 'hyperscript.org';
import './_htmx.js'; import './_htmx.js';
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import mask from '@alpinejs/mask'; import mask from '@alpinejs/mask';
@@ -6,8 +6,10 @@ import collapse from '@alpinejs/collapse'
import { create, all } from 'mathjs'; import { create, all } from 'mathjs';
window.Alpine = Alpine; window.Alpine = Alpine;
const _hyperscript = window._hyperscript; if (!window._hyperscript) {
window._hyperscript = _hyperscript; window._hyperscript = _hyperscript;
_hyperscript.browserInit();
}
window.math = create(all, { window.math = create(all, {
number: 'BigNumber', number: 'BigNumber',
}); });
@@ -0,0 +1,3 @@
import twBootstrapGrid from "tw-bootstrap-grid";
export default twBootstrapGrid;
+5 -1
View File
@@ -49,6 +49,10 @@ div:where(.swal2-container) {
z-index: 1101 !important; z-index: 1101 !important;
} }
#toasts .toast-container {
z-index: 1100;
}
.logo { .logo {
/* Set the background-color to DaisyUI CSS variable */ /* Set the background-color to DaisyUI CSS variable */
background-color: var(--color-primary); background-color: var(--color-primary);
@@ -77,4 +81,4 @@ div:where(.swal2-container) {
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }
+1 -1
View File
@@ -4,7 +4,7 @@
themes: wygiwyh_dark --default, wygiwyh_light; themes: wygiwyh_dark --default, wygiwyh_light;
logs: true; logs: true;
} }
@plugin "tw-bootstrap-grid"; @plugin "../plugins/tw-bootstrap-grid-plugin.js";
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "wygiwyh_light"; name: "wygiwyh_light";