mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-06-07 23:22:51 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee32724f1 | |||
| 6a19381672 | |||
| 248fec8b4c | |||
| b34c0557fa | |||
| 2af4066aab | |||
| d72ff3cdf5 | |||
| 63c69e5c6a | |||
| 78171183cc | |||
| 34a2b6bfd4 | |||
| 1dc24f855e | |||
| 1390aff07d |
@@ -165,3 +165,6 @@ cython_debug/
|
||||
node_modules/
|
||||
postgres_data/
|
||||
.prod.env
|
||||
|
||||
# Private local uploads
|
||||
app/attachments/
|
||||
|
||||
@@ -311,6 +311,7 @@ LOCALE_PATHS = [BASE_DIR / "locale"]
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static_files"
|
||||
ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments"
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
ROOT_DIR / "frontend" / "build",
|
||||
@@ -440,14 +441,14 @@ REST_FRAMEWORK = {
|
||||
"apps.api.permissions.NotInDemoMode",
|
||||
"rest_framework.permissions.DjangoModelPermissions",
|
||||
],
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.BasicAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
|
||||
@@ -90,10 +90,10 @@ class AccountBalanceAPITests(TestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_get_balance_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
"""Test unauthenticated request returns 401"""
|
||||
unauthenticated_client = APIClient()
|
||||
response = unauthenticated_client.get(
|
||||
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)
|
||||
|
||||
def test_unauthenticated_request(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
"""Test unauthenticated request returns 401"""
|
||||
unauthenticated_client = APIClient()
|
||||
|
||||
csv_content = b"date,description,amount\n2025-01-01,Test,100"
|
||||
@@ -173,7 +173,7 @@ column_mapping:
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -266,11 +266,11 @@ column_mapping:
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_profiles_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
"""Test unauthenticated request returns 401"""
|
||||
unauthenticated_client = APIClient()
|
||||
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(
|
||||
@@ -397,8 +397,8 @@ column_mapping:
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_runs_unauthenticated(self):
|
||||
"""Test unauthenticated request returns 403"""
|
||||
"""Test unauthenticated request returns 401"""
|
||||
unauthenticated_client = APIClient()
|
||||
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
|
||||
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):
|
||||
"""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:
|
||||
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)
|
||||
else:
|
||||
transaction.category = TransactionCategory.objects.get(name=value)
|
||||
@@ -458,7 +460,9 @@ def check_for_transaction_rules(
|
||||
transaction.account = account
|
||||
|
||||
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)
|
||||
transaction.category = category
|
||||
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.transactions.models import (
|
||||
InstallmentPlan,
|
||||
TransactionAttachment,
|
||||
QuickTransaction,
|
||||
RecurringTransaction,
|
||||
Transaction,
|
||||
@@ -36,6 +37,22 @@ from django.db.models import Q
|
||||
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):
|
||||
category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
@@ -247,6 +264,41 @@ class TransactionForm(forms.ModelForm):
|
||||
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):
|
||||
category = DynamicModelChoiceField(
|
||||
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 logging
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from apps.common.fields.month_year import MonthYearModelField
|
||||
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.currencies.utils.convert import convert
|
||||
from apps.transactions.storage import PrivateMediaStorage
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import Signal
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.template.defaultfilters import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -32,6 +36,11 @@ transaction_updated = 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):
|
||||
@staticmethod
|
||||
def _emit_signals(instances, created=False, old_data=None):
|
||||
@@ -526,6 +535,60 @@ class Transaction(OwnedObject):
|
||||
|
||||
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 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,
|
||||
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(
|
||||
"transaction/<int:transaction_id>/delete/",
|
||||
views.transaction_delete,
|
||||
|
||||
@@ -1,32 +1,120 @@
|
||||
import datetime
|
||||
from copy import deepcopy
|
||||
|
||||
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 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.demo import disabled_on_demo
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.transactions.filters import TransactionsFilter
|
||||
from apps.transactions.forms import (
|
||||
BulkEditTransactionForm,
|
||||
TransactionAttachmentForm,
|
||||
TransactionForm,
|
||||
TransferForm,
|
||||
BulkEditTransactionForm,
|
||||
)
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.models import Transaction, TransactionAttachment
|
||||
from apps.transactions.utils.calculations import (
|
||||
calculate_currency_totals,
|
||||
calculate_account_totals,
|
||||
calculate_currency_totals,
|
||||
calculate_percentage_distribution,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \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"
|
||||
"Language-Team: Russian <https://translations.herculino.com/projects/wygiwyh/"
|
||||
"app/ru/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"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"
|
||||
"X-Generator: Weblate 5.16.2\n"
|
||||
"X-Generator: Weblate 5.17\n"
|
||||
|
||||
#: apps/accounts/forms.py:24
|
||||
msgid "Group name"
|
||||
@@ -1513,7 +1513,7 @@ msgstr ""
|
||||
#: templates/insights/fragments/category_overview/index.html:87
|
||||
#: templates/monthly_overview/fragments/monthly_summary.html:41
|
||||
msgid "Income"
|
||||
msgstr ""
|
||||
msgstr "Доход"
|
||||
|
||||
#: apps/transactions/models.py:289 apps/transactions/models.py:988
|
||||
#: templates/calendar_view/fragments/list.html:46
|
||||
@@ -2178,7 +2178,7 @@ msgstr "Вы уверены?"
|
||||
#: templates/rules/fragments/transaction_rule/view.html:97
|
||||
#: templates/tags/fragments/table.html:56
|
||||
msgid "You won't be able to revert this!"
|
||||
msgstr ""
|
||||
msgstr "Вы не сможете отменить это!"
|
||||
|
||||
#: templates/account_groups/fragments/list.html:60
|
||||
#: templates/accounts/fragments/list.html:77
|
||||
@@ -2237,7 +2237,7 @@ msgstr "Сверка балансов"
|
||||
|
||||
#: templates/accounts/fragments/add.html:5
|
||||
msgid "Add account"
|
||||
msgstr ""
|
||||
msgstr "Добавить счёт"
|
||||
|
||||
#: templates/accounts/fragments/edit.html:5
|
||||
msgid "Edit account"
|
||||
@@ -2688,7 +2688,7 @@ msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:17
|
||||
msgid "Fetch all"
|
||||
msgstr ""
|
||||
msgstr "Отметить все"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:29
|
||||
msgid "Service"
|
||||
@@ -2719,7 +2719,7 @@ msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
msgstr "Службы не настроены"
|
||||
|
||||
#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:205
|
||||
msgid "Export and Restore"
|
||||
@@ -2751,7 +2751,7 @@ msgstr ""
|
||||
|
||||
#: templates/import_app/fragments/profiles/list.html:80
|
||||
msgid "No import profiles"
|
||||
msgstr ""
|
||||
msgstr "Нет профилей для импорта"
|
||||
|
||||
#: templates/import_app/fragments/profiles/list_presets.html:5
|
||||
msgid "Import Presets"
|
||||
@@ -2877,11 +2877,11 @@ msgstr ""
|
||||
|
||||
#: templates/includes/sidebar.html:69 templates/insights/pages/index.html:5
|
||||
msgid "Insights"
|
||||
msgstr ""
|
||||
msgstr "Аналитика"
|
||||
|
||||
#: templates/includes/sidebar.html:75
|
||||
msgid "Net Worth"
|
||||
msgstr ""
|
||||
msgstr "Чистый капитал"
|
||||
|
||||
#: templates/includes/sidebar.html:91
|
||||
msgid "Trash Can"
|
||||
@@ -2899,7 +2899,7 @@ msgstr "Трекер средней стоимости доллара"
|
||||
#: templates/mini_tools/unit_price_calculator.html:4
|
||||
#: templates/mini_tools/unit_price_calculator.html:9
|
||||
msgid "Unit Price Calculator"
|
||||
msgstr ""
|
||||
msgstr "Калькулятор цены за единицу"
|
||||
|
||||
#: templates/includes/sidebar.html:130
|
||||
#: templates/mini_tools/currency_converter/currency_converter.html:7
|
||||
@@ -2909,7 +2909,7 @@ msgstr "Конвертер валют"
|
||||
|
||||
#: templates/includes/sidebar.html:139
|
||||
msgid "Management"
|
||||
msgstr ""
|
||||
msgstr "Управление"
|
||||
|
||||
#: templates/includes/sidebar.html:190
|
||||
msgid "Automation"
|
||||
@@ -3025,7 +3025,7 @@ msgstr "Всё отлично!"
|
||||
|
||||
#: templates/insights/fragments/late_transactions.html:16
|
||||
msgid "No late transactions"
|
||||
msgstr ""
|
||||
msgstr "Нет просроченных транзакций"
|
||||
|
||||
#: templates/insights/fragments/latest_transactions.html:14
|
||||
msgid "No recent transactions"
|
||||
@@ -3123,31 +3123,31 @@ msgstr "Диапазон дат"
|
||||
|
||||
#: templates/insights/pages/index.html:79
|
||||
msgid "Account Flow"
|
||||
msgstr ""
|
||||
msgstr "Движение по счету"
|
||||
|
||||
#: templates/insights/pages/index.html:84
|
||||
msgid "Currency Flow"
|
||||
msgstr ""
|
||||
msgstr "Движение валюты"
|
||||
|
||||
#: templates/insights/pages/index.html:89
|
||||
msgid "Category Explorer"
|
||||
msgstr ""
|
||||
msgstr "Обзор по категориям"
|
||||
|
||||
#: templates/insights/pages/index.html:94
|
||||
msgid "Categories Overview"
|
||||
msgstr ""
|
||||
msgstr "Все категории"
|
||||
|
||||
#: templates/insights/pages/index.html:112
|
||||
msgid "Late Transactions"
|
||||
msgstr ""
|
||||
msgstr "Просроченные транзакции"
|
||||
|
||||
#: templates/insights/pages/index.html:117
|
||||
msgid "Latest Transactions"
|
||||
msgstr ""
|
||||
msgstr "Последние транзакции"
|
||||
|
||||
#: templates/insights/pages/index.html:122
|
||||
msgid "Emergency Fund"
|
||||
msgstr ""
|
||||
msgstr "Резервный фонд"
|
||||
|
||||
#: templates/insights/pages/index.html:127
|
||||
msgid "Year by Year"
|
||||
|
||||
@@ -147,6 +147,12 @@
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML"
|
||||
data-tippy-content="{% translate "Edit" %}">
|
||||
<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"
|
||||
role="button"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,6 +1,7 @@
|
||||
volumes:
|
||||
wygiwyh_dev_postgres_data: {}
|
||||
wygiwyh_temp:
|
||||
wygiwyh_attachments:
|
||||
|
||||
|
||||
services:
|
||||
@@ -14,6 +15,7 @@ services:
|
||||
- ./app/:/usr/src/app/:z
|
||||
- ./frontend/:/usr/src/frontend:z
|
||||
- wygiwyh_temp:/usr/src/app/temp/
|
||||
- wygiwyh_attachments:/usr/src/app/attachments/
|
||||
ports:
|
||||
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
|
||||
env_file:
|
||||
|
||||
@@ -6,6 +6,8 @@ services:
|
||||
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./media:/usr/src/app/attachments/
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -49,6 +49,10 @@ div:where(.swal2-container) {
|
||||
z-index: 1101 !important;
|
||||
}
|
||||
|
||||
#toasts .toast-container {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
/* Set the background-color to DaisyUI CSS variable */
|
||||
background-color: var(--color-primary);
|
||||
@@ -77,4 +81,4 @@ div:where(.swal2-container) {
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user