mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-06-07 15:12:51 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee32724f1 | |||
| 6a19381672 | |||
| 248fec8b4c | |||
| b34c0557fa | |||
| 2af4066aab | |||
| d72ff3cdf5 | |||
| 63c69e5c6a | |||
| 78171183cc | |||
| 34a2b6bfd4 | |||
| 1dc24f855e | |||
| 1390aff07d | |||
| 8fc11b0acf | |||
| 9a30a0d3c0 | |||
| 10eecd09ff | |||
| 2cfb3fb12e | |||
| 47af8b135b | |||
| 39d0e63375 | |||
| 792154eba2 | |||
| e627dd50be | |||
| be24ca014e |
@@ -165,3 +165,6 @@ cython_debug/
|
|||||||
node_modules/
|
node_modules/
|
||||||
postgres_data/
|
postgres_data/
|
||||||
.prod.env
|
.prod.env
|
||||||
|
|
||||||
|
# Private local uploads
|
||||||
|
app/attachments/
|
||||||
|
|||||||
Vendored
+29
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+119
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
Generated
+14
-11
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user