Compare commits

..

3 Commits

Author SHA1 Message Date
Herculino Trotta 0ee32724f1 style(toas): move toast to the top of offcanvas 2026-06-06 04:33:23 -03:00
Herculino Trotta 6a19381672 feat(transactions): add attachments 2026-06-06 04:33:06 -03:00
Herculino Trotta 248fec8b4c Merge pull request #541 from eitchtee/weblate
Translations update from Weblate
2026-05-02 16:30:56 -03:00
15 changed files with 579 additions and 23 deletions
+3
View File
@@ -165,3 +165,6 @@ cython_debug/
node_modules/ node_modules/
postgres_data/ postgres_data/
.prod.env .prod.env
# Private local uploads
app/attachments/
+8 -7
View File
@@ -311,6 +311,7 @@ LOCALE_PATHS = [BASE_DIR / "locale"]
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static_files" STATIC_ROOT = BASE_DIR / "static_files"
ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments"
STATICFILES_DIRS = [ STATICFILES_DIRS = [
ROOT_DIR / "frontend" / "build", ROOT_DIR / "frontend" / "build",
@@ -440,14 +441,14 @@ REST_FRAMEWORK = {
"apps.api.permissions.NotInDemoMode", "apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions", "rest_framework.permissions.DjangoModelPermissions",
], ],
'DEFAULT_FILTER_BACKENDS': [ "DEFAULT_FILTER_BACKENDS": [
'django_filters.rest_framework.DjangoFilterBackend', "django_filters.rest_framework.DjangoFilterBackend",
'rest_framework.filters.OrderingFilter', "rest_framework.filters.OrderingFilter",
], ],
'DEFAULT_AUTHENTICATION_CLASSES': [ "DEFAULT_AUTHENTICATION_CLASSES": [
'rest_framework.authentication.BasicAuthentication', "rest_framework.authentication.BasicAuthentication",
'rest_framework.authentication.SessionAuthentication', "rest_framework.authentication.SessionAuthentication",
'rest_framework.authentication.TokenAuthentication', "rest_framework.authentication.TokenAuthentication",
], ],
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination", "DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+52
View File
@@ -14,6 +14,7 @@ from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import ( from apps.transactions.models import (
InstallmentPlan, InstallmentPlan,
TransactionAttachment,
QuickTransaction, QuickTransaction,
RecurringTransaction, RecurringTransaction,
Transaction, Transaction,
@@ -36,6 +37,22 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
widget = MultipleFileInput
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(file, initial) for file in data]
if data:
return [single_file_clean(data, initial)]
return []
class TransactionForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name", create_field="name",
@@ -247,6 +264,41 @@ class TransactionForm(forms.ModelForm):
return instance return instance
class TransactionAttachmentForm(forms.Form):
attachments = MultipleFileField(
required=True,
label=_("Attachments"),
help_text=_("Files are private and only visible to users with access to this transaction."),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"attachments",
FormActions(
NoClassSubmit("submit", _("Upload"), css_class="btn btn-primary"),
),
)
def save(self, transaction, uploaded_by):
created = []
for attachment in self.cleaned_data.get("attachments") or []:
created.append(
TransactionAttachment.objects.create(
transaction=transaction,
file=attachment,
original_name=attachment.name,
content_type=getattr(attachment, "content_type", ""),
size=attachment.size,
uploaded_by=uploaded_by,
)
)
return created
class QuickTransactionForm(forms.ModelForm): class QuickTransactionForm(forms.ModelForm):
category = DynamicModelChoiceField( category = DynamicModelChoiceField(
create_field="name", create_field="name",
@@ -0,0 +1,38 @@
# Generated by Django 5.2.13 on 2026-06-06 02:34
import apps.transactions.models
import apps.transactions.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0048_recurringtransaction_keep_at_most'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TransactionAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(storage=apps.transactions.storage.PrivateMediaStorage(), upload_to=apps.transactions.models.transaction_attachment_path, verbose_name='File')),
('original_name', models.CharField(max_length=255, verbose_name='Original Name')),
('content_type', models.CharField(blank=True, max_length=255, verbose_name='Content Type')),
('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')),
('created_at', models.DateTimeField(auto_now_add=True)),
('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='transactions.transaction', verbose_name='Transaction')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Transaction Attachment',
'verbose_name_plural': 'Transaction Attachments',
'db_table': 'transaction_attachments',
'ordering': ['-created_at', 'original_name'],
},
),
]
+64 -1
View File
@@ -1,6 +1,8 @@
import decimal import decimal
import logging import logging
import uuid
from copy import deepcopy from copy import deepcopy
from pathlib import Path
from apps.common.fields.month_year import MonthYearModelField from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal from apps.common.functions.decimals import truncate_decimal
@@ -13,13 +15,15 @@ from apps.common.models import (
) )
from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number
from apps.currencies.utils.convert import convert from apps.currencies.utils.convert import convert
from apps.transactions.storage import PrivateMediaStorage
from apps.transactions.validators import validate_decimal_places, validate_non_negative from apps.transactions.validators import validate_decimal_places, validate_non_negative
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.dispatch import Signal from django.db.models.signals import post_delete
from django.dispatch import Signal, receiver
from django.template.defaultfilters import date from django.template.defaultfilters import date
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -32,6 +36,11 @@ transaction_updated = Signal()
transaction_deleted = Signal() transaction_deleted = Signal()
def transaction_attachment_path(instance, filename):
extension = Path(filename).suffix
return f"transaction_attachments/{instance.transaction_id}/{instance.id}{extension}"
class SoftDeleteQuerySet(models.QuerySet): class SoftDeleteQuerySet(models.QuerySet):
@staticmethod @staticmethod
def _emit_signals(instances, created=False, old_data=None): def _emit_signals(instances, created=False, old_data=None):
@@ -526,6 +535,60 @@ class Transaction(OwnedObject):
return new_obj return new_obj
class TransactionAttachment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
transaction = models.ForeignKey(
Transaction,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name=_("Transaction"),
)
file = models.FileField(
upload_to=transaction_attachment_path,
storage=PrivateMediaStorage(),
verbose_name=_("File"),
)
original_name = models.CharField(max_length=255, verbose_name=_("Original Name"))
content_type = models.CharField(
max_length=255, blank=True, verbose_name=_("Content Type")
)
size = models.PositiveBigIntegerField(default=0, verbose_name=_("Size"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="transaction_attachments",
verbose_name=_("Uploaded By"),
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Transaction Attachment")
verbose_name_plural = _("Transaction Attachments")
db_table = "transaction_attachments"
ordering = ["-created_at", "original_name"]
def save(self, *args, **kwargs):
if self.file:
if not self.original_name:
self.original_name = Path(self.file.name).name
if not self.size:
self.size = self.file.size
if not self.content_type:
self.content_type = getattr(self.file.file, "content_type", "")
super().save(*args, **kwargs)
def __str__(self):
return self.original_name
@receiver(post_delete, sender=TransactionAttachment)
def delete_transaction_attachment_file(sender, instance, **kwargs):
if not instance.file.name:
return
storage = instance.file.storage
if storage.exists(instance.file.name):
storage.delete(instance.file.name)
class InstallmentPlan(models.Model): class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices): class Recurrence(models.TextChoices):
+9
View File
@@ -0,0 +1,9 @@
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class PrivateMediaStorage(FileSystemStorage):
def __init__(self, *args, **kwargs):
kwargs.setdefault("location", settings.ATTACHMENT_MEDIA_ROOT)
kwargs.setdefault("base_url", None)
super().__init__(*args, **kwargs)
@@ -0,0 +1,219 @@
import shutil
import tempfile
from datetime import date
from decimal import Decimal
from pathlib import Path
from apps.accounts.models import Account
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.transactions.models import Transaction, TransactionAttachment
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionAttachmentTests(TestCase):
def setUp(self):
self.attachment_media_root = tempfile.mkdtemp()
self.override_private_media = override_settings(
ATTACHMENT_MEDIA_ROOT=self.attachment_media_root
)
self.override_private_media.enable()
self.addCleanup(self.override_private_media.disable)
self.addCleanup(shutil.rmtree, self.attachment_media_root, ignore_errors=True)
self.attachment_storage = TransactionAttachment._meta.get_field("file").storage
self.original_storage_location = self.attachment_storage._location
self.attachment_storage._location = self.attachment_media_root
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
self.addCleanup(self.restore_attachment_storage)
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.user1_account = Account.all_objects.create(
name="User1 Account", currency=self.currency, owner=self.user1
)
self.user2_account = Account.all_objects.create(
name="User2 Account", currency=self.currency, owner=self.user2
)
self.transaction = Transaction.userless_all_objects.create(
account=self.user1_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("12.34"),
is_paid=True,
date=date(2026, 6, 5),
description="Receipt transaction",
owner=self.user1,
)
self.other_transaction = Transaction.userless_all_objects.create(
account=self.user2_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("56.78"),
is_paid=True,
date=date(2026, 6, 5),
description="Other receipt transaction",
owner=self.user2,
)
def restore_attachment_storage(self):
self.attachment_storage._location = self.original_storage_location
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
def test_attachment_uses_uuid_and_preserves_original_download_name(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile(
"receipt June.pdf", b"receipt bytes", content_type="application/pdf"
),
uploaded_by=self.user1,
)
self.assertEqual(attachment.original_name, "receipt June.pdf")
self.assertNotIn("receipt June.pdf", attachment.file.name)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(b"".join(response.streaming_content), b"receipt bytes")
self.assertIn('filename="receipt June.pdf"', response["Content-Disposition"])
def test_user_without_transaction_access_cannot_download_attachment(self):
attachment = TransactionAttachment.objects.create(
transaction=self.other_transaction,
file=SimpleUploadedFile("private.txt", b"private"),
uploaded_by=self.user2,
)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 404)
def test_attachment_button_lives_in_transaction_hover_toolbar(self):
template = Path("templates/cotton/transaction/item.html").read_text()
before_toolbar, toolbar = template.split("{# Item actions#}", 1)
self.assertNotIn("transaction_attachments", before_toolbar)
self.assertLess(
toolbar.index("transaction_edit"),
toolbar.index("transaction_attachments"),
)
self.assertLess(
toolbar.index("transaction_attachments"),
toolbar.index("transaction_delete"),
)
def test_transaction_edit_form_does_not_include_attachment_upload(self):
self.client.force_login(self.user1)
response = self.client.get(
reverse("transaction_edit", kwargs={"transaction_id": self.transaction.id}),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "multipart/form-data")
self.assertNotContains(response, 'type="file"')
def test_attachment_management_uploads_multiple_attachments(self):
self.client.force_login(self.user1)
response = self.client.post(
reverse(
"transaction_attachments",
kwargs={"transaction_id": self.transaction.id},
),
{
"attachments": [
SimpleUploadedFile("first.txt", b"first"),
SimpleUploadedFile("second.txt", b"second"),
],
},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "first.txt")
self.assertContains(response, "second.txt")
self.assertEqual(self.transaction.attachments.count(), 2)
def test_attachment_delete_returns_refreshed_attachment_list(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("delete-me.txt", b"delete"),
uploaded_by=self.user1,
)
self.client.force_login(self.user1)
response = self.client.delete(
reverse(
"transaction_attachment_delete",
kwargs={"attachment_id": attachment.id},
),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "delete-me.txt")
self.assertContains(response, "No attachments yet")
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
def test_hard_deleting_transaction_deletes_attachment_files(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("hard-delete.txt", b"delete with transaction"),
uploaded_by=self.user1,
)
file_path = Path(attachment.file.path)
self.assertTrue(file_path.exists())
write_current_user(self.user1)
self.addCleanup(delete_current_user)
self.transaction.delete()
self.assertTrue(file_path.exists())
self.assertTrue(TransactionAttachment.objects.filter(id=attachment.id).exists())
self.transaction.delete()
self.assertFalse(file_path.exists())
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
+20
View File
@@ -81,6 +81,26 @@ urlpatterns = [
views.transaction_move_to_today, views.transaction_move_to_today,
name="transaction_move_to_today", name="transaction_move_to_today",
), ),
path(
"transaction/<int:transaction_id>/attachments/",
views.transaction_attachments,
name="transaction_attachments",
),
path(
"transaction/<int:transaction_id>/attachments/list/",
views.transaction_attachments_list,
name="transaction_attachments_list",
),
path(
"transaction/attachments/<uuid:attachment_id>/download/",
views.transaction_attachment_download,
name="transaction_attachment_download",
),
path(
"transaction/attachments/<uuid:attachment_id>/delete/",
views.transaction_attachment_delete,
name="transaction_attachment_delete",
),
path( path(
"transaction/<int:transaction_id>/delete/", "transaction/<int:transaction_id>/delete/",
views.transaction_delete, views.transaction_delete,
+102 -14
View File
@@ -1,32 +1,120 @@
import datetime import datetime
from copy import deepcopy from copy import deepcopy
from dateutil.relativedelta import relativedelta from apps.common.decorators.demo import disabled_on_demo
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, When, Case, Value, IntegerField
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
from apps.rules.signals import transaction_created, transaction_updated from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import ( from apps.transactions.forms import (
BulkEditTransactionForm,
TransactionAttachmentForm,
TransactionForm, TransactionForm,
TransferForm, TransferForm,
BulkEditTransactionForm,
) )
from apps.transactions.models import Transaction from apps.transactions.models import Transaction, TransactionAttachment
from apps.transactions.utils.calculations import ( from apps.transactions.utils.calculations import (
calculate_currency_totals,
calculate_account_totals, calculate_account_totals,
calculate_currency_totals,
calculate_percentage_distribution, calculate_percentage_distribution,
) )
from apps.transactions.utils.default_ordering import default_order from apps.transactions.utils.default_ordering import default_order
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from django.views.decorators.http import require_http_methods
def _get_accessible_transaction_or_404(transaction_id):
return get_object_or_404(Transaction.objects, id=transaction_id)
def _get_accessible_attachment_or_404(attachment_id):
attachment = get_object_or_404(
TransactionAttachment.objects.select_related("transaction"),
id=attachment_id,
)
if not Transaction.objects.filter(id=attachment.transaction_id).exists():
raise Http404()
return attachment
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_attachments(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
if request.method == "POST":
form = TransactionAttachmentForm(request.POST, request.FILES)
if form.is_valid():
form.save(transaction=transaction, uploaded_by=request.user)
messages.success(request, _("Attachment uploaded successfully"))
form = TransactionAttachmentForm()
else:
form = TransactionAttachmentForm()
response = render(
request,
"transactions/fragments/attachments_manage.html",
{"form": form, "transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachments_list(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
return render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachment_download(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
return FileResponse(
attachment.file.open("rb"),
as_attachment=False,
filename=attachment.original_name,
content_type=attachment.content_type or "application/octet-stream",
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_attachment_delete(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
transaction = attachment.transaction
attachment.file.delete(save=False)
attachment.delete()
messages.success(request, _("Attachment deleted successfully"))
response = render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx @only_htmx
@@ -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 %}"
@@ -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 %}
+2
View File
@@ -1,6 +1,7 @@
volumes: volumes:
wygiwyh_dev_postgres_data: {} wygiwyh_dev_postgres_data: {}
wygiwyh_temp: wygiwyh_temp:
wygiwyh_attachments:
services: services:
@@ -14,6 +15,7 @@ services:
- ./app/:/usr/src/app/:z - ./app/:/usr/src/app/:z
- ./frontend/:/usr/src/frontend:z - ./frontend/:/usr/src/frontend:z
- wygiwyh_temp:/usr/src/app/temp/ - wygiwyh_temp:/usr/src/app/temp/
- wygiwyh_attachments:/usr/src/app/attachments/
ports: ports:
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}" - "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file: env_file:
+2
View File
@@ -6,6 +6,8 @@ services:
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}" - "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file: env_file:
- .env - .env
volumes:
- ./media:/usr/src/app/attachments/
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
+5 -1
View File
@@ -49,6 +49,10 @@ div:where(.swal2-container) {
z-index: 1101 !important; z-index: 1101 !important;
} }
#toasts .toast-container {
z-index: 1100;
}
.logo { .logo {
/* Set the background-color to DaisyUI CSS variable */ /* Set the background-color to DaisyUI CSS variable */
background-color: var(--color-primary); background-color: var(--color-primary);
@@ -77,4 +81,4 @@ div:where(.swal2-container) {
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
} }