From 6a19381672eb29c527da10a59560033a8b49cf16 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 6 Jun 2026 04:33:06 -0300 Subject: [PATCH] feat(transactions): add attachments --- .gitignore | 3 + app/WYGIWYH/settings.py | 15 +- app/apps/transactions/forms.py | 52 +++++ .../migrations/0049_transactionattachment.py | 38 +++ app/apps/transactions/models.py | 65 +++++- app/apps/transactions/storage.py | 9 + .../transactions/tests/test_attachments.py | 219 ++++++++++++++++++ app/apps/transactions/urls.py | 20 ++ app/apps/transactions/views/transactions.py | 116 ++++++++-- app/templates/cotton/transaction/item.html | 6 + .../transactions/fragments/attachments.html | 32 +++ .../fragments/attachments_manage.html | 17 ++ docker-compose.dev.yml | 2 + docker-compose.prod.yml | 2 + 14 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 app/apps/transactions/migrations/0049_transactionattachment.py create mode 100644 app/apps/transactions/storage.py create mode 100644 app/apps/transactions/tests/test_attachments.py create mode 100644 app/templates/transactions/fragments/attachments.html create mode 100644 app/templates/transactions/fragments/attachments_manage.html diff --git a/.gitignore b/.gitignore index d6d659c..93ba54a 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,6 @@ cython_debug/ node_modules/ postgres_data/ .prod.env + +# Private local uploads +app/attachments/ diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index fa6adb3..6598562 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -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", diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 04fafe8..a5ef704 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -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", diff --git a/app/apps/transactions/migrations/0049_transactionattachment.py b/app/apps/transactions/migrations/0049_transactionattachment.py new file mode 100644 index 0000000..feba991 --- /dev/null +++ b/app/apps/transactions/migrations/0049_transactionattachment.py @@ -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'], + }, + ), + ] diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index fed5398..be85547 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -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): diff --git a/app/apps/transactions/storage.py b/app/apps/transactions/storage.py new file mode 100644 index 0000000..7478da6 --- /dev/null +++ b/app/apps/transactions/storage.py @@ -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) diff --git a/app/apps/transactions/tests/test_attachments.py b/app/apps/transactions/tests/test_attachments.py new file mode 100644 index 0000000..52f118a --- /dev/null +++ b/app/apps/transactions/tests/test_attachments.py @@ -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() + ) diff --git a/app/apps/transactions/urls.py b/app/apps/transactions/urls.py index d056758..0ef9c1d 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -81,6 +81,26 @@ urlpatterns = [ views.transaction_move_to_today, name="transaction_move_to_today", ), + path( + "transaction//attachments/", + views.transaction_attachments, + name="transaction_attachments", + ), + path( + "transaction//attachments/list/", + views.transaction_attachments_list, + name="transaction_attachments_list", + ), + path( + "transaction/attachments//download/", + views.transaction_attachment_download, + name="transaction_attachment_download", + ), + path( + "transaction/attachments//delete/", + views.transaction_attachment_delete, + name="transaction_attachment_delete", + ), path( "transaction//delete/", views.transaction_delete, diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index 693a3c6..5ff95b4 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -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 diff --git a/app/templates/cotton/transaction/item.html b/app/templates/cotton/transaction/item.html index d410784..82ff696 100644 --- a/app/templates/cotton/transaction/item.html +++ b/app/templates/cotton/transaction/item.html @@ -147,6 +147,12 @@ hx-target="#generic-offcanvas" hx-swap="innerHTML" data-tippy-content="{% translate "Edit" %}"> + + {{ transaction.attachments.count }} +

{% translate 'Attachments' %}

+ {% if transaction.attachments.exists %} +
+ {% else %} +

{% translate 'No attachments yet' %}

+ {% endif %} + \ No newline at end of file diff --git a/app/templates/transactions/fragments/attachments_manage.html b/app/templates/transactions/fragments/attachments_manage.html new file mode 100644 index 0000000..1221c62 --- /dev/null +++ b/app/templates/transactions/fragments/attachments_manage.html @@ -0,0 +1,17 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Transaction attachments' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+ +{% include 'transactions/fragments/attachments.html' %} +{% endblock %} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 58b74cb..96828b9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e79e105..4433873 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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