Merge pull request #653 from eitchtee/fix/transfer-form-fields

fix(transfers): entities, paid state and inactive items in the transfer form
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-11-08 17:19:41 -03:00
commit d9c296c26b
3 files changed
+97 -20

No files matched your search

+49 -18
View File
@@ -632,6 +632,23 @@ class TransferForm(forms.Form):
queryset=TransactionTag.objects.filter(active=True),
)
from_entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
)
to_entities = DynamicModelMultipleChoiceField(
model=TransactionEntity,
to_field_name="name",
create_field="name",
required=False,
label=_("Entities"),
)
is_paid = forms.BooleanField(label=_("Paid"), initial=True, required=False)
date = forms.DateField(label=_("Date"))
reference_date = forms.DateField(
@@ -672,11 +689,14 @@ class TransferForm(forms.Form):
"to_category": to_t.category,
"from_tags": list(from_t.tags.all()),
"to_tags": list(to_t.tags.all()),
"from_entities": list(from_t.entities.all()),
"to_entities": list(to_t.entities.all()),
"date": from_t.date,
"reference_date": from_t.reference_date,
"description": from_t.description,
"notes": from_t.notes,
"mute": from_t.mute,
"is_paid": from_t.is_paid,
}
super().__init__(*args, **kwargs)
@@ -685,6 +705,7 @@ class TransferForm(forms.Form):
self.helper.form_method = "post"
self.helper.layout = Layout(
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
Row(
Column(Field("date")),
Column(
@@ -699,6 +720,7 @@ class TransferForm(forms.Form):
Column(Field("from_amount")),
Column("from_category"),
Column("from_tags"),
Column("from_entities"),
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
),
Row(
@@ -710,6 +732,7 @@ class TransferForm(forms.Form):
),
Column("to_category"),
Column("to_tags"),
Column("to_entities"),
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
),
FormActions(
@@ -725,23 +748,28 @@ class TransferForm(forms.Form):
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
self.fields["from_account"].queryset = Account.objects.filter(
is_archived=False,
# When editing, keep the transfer's own archived/inactive items selectable,
# like TransactionForm does, so saving doesn't drop them.
own = (
[instance.transfer_from.pk, instance.transfer_to.pk] if instance else []
)
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["from_tags"].queryset = TransactionTag.objects.filter(active=True)
self.fields["to_account"].queryset = Account.objects.filter(
is_archived=False,
)
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
active=True
)
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
accounts = Account.objects.filter(
Q(is_archived=False) | Q(transactions__in=own)
).distinct()
categories = TransactionCategory.objects.filter(
Q(active=True) | Q(transaction__in=own)
).distinct()
tags = TransactionTag.objects.filter(
Q(active=True) | Q(transaction__in=own)
).distinct()
entities = TransactionEntity.objects.filter(
Q(active=True) | Q(transactions__in=own)
).distinct()
for side in ("from", "to"):
self.fields[f"{side}_account"].queryset = accounts
self.fields[f"{side}_category"].queryset = categories
self.fields[f"{side}_tags"].queryset = tags
self.fields[f"{side}_entities"].queryset = entities
def clean(self):
cleaned_data = super().clean()
@@ -765,8 +793,8 @@ class TransferForm(forms.Form):
for t in (from_transaction, to_transaction)
]
else:
from_transaction = Transaction(type=Transaction.Type.EXPENSE, is_paid=True)
to_transaction = Transaction(type=Transaction.Type.INCOME, is_paid=True)
from_transaction = Transaction(type=Transaction.Type.EXPENSE)
to_transaction = Transaction(type=Transaction.Type.INCOME)
for transaction, side in ((from_transaction, "from"), (to_transaction, "to")):
transaction.account = data[f"{side}_account"]
@@ -776,6 +804,7 @@ class TransferForm(forms.Form):
transaction.description = data["description"]
transaction.notes = data.get("notes")
transaction.mute = data["mute"]
transaction.is_paid = data["is_paid"]
from_transaction.amount = data["from_amount"]
to_transaction.amount = data["to_amount"] or data["from_amount"]
@@ -791,6 +820,8 @@ class TransferForm(forms.Form):
from_transaction.tags.set(data.get("from_tags", []))
to_transaction.tags.set(data.get("to_tags", []))
from_transaction.entities.set(data.get("from_entities", []))
to_transaction.entities.set(data.get("to_entities", []))
# Rules run for each side, like any other transaction
for i, transaction in enumerate((from_transaction, to_transaction)):
+38 -2
View File
@@ -15,6 +15,7 @@ from apps.transactions.models import (
RecurringTransaction,
Transaction,
TransactionCategory,
TransactionEntity,
TransactionTag,
)
from apps.rules.signals import transaction_created, transaction_updated
@@ -53,6 +54,7 @@ class TransferTestBase(TestCase):
"reference_date": "2026-03-01",
"description": "Move money",
"mute": True,
"is_paid": "true",
**overrides,
}
form = TransferForm(data)
@@ -73,6 +75,12 @@ class TransferTests(TransferTestBase):
self.assertEqual(from_t.amount, Decimal("100"))
self.assertEqual(to_t.amount, Decimal("98"))
def test_paid_toggle_applies_to_both_sides(self):
from_t, to_t = self._transfer(is_paid="false")
self.assertFalse(from_t.is_paid)
self.assertFalse(to_t.is_paid)
self.assertFalse(TransferForm(instance=from_t).initial["is_paid"])
def test_listing_collapses_pair_unless_one_side_is_filtered_out(self):
from_t, to_t = self._transfer()
Transaction.objects.create(
@@ -195,21 +203,49 @@ class TransferTests(TransferTestBase):
in_cat = TransactionCategory.objects.create(name="InCat")
out_tag = TransactionTag.objects.create(name="OutTag")
in_tag = TransactionTag.objects.create(name="InTag")
self._transfer(
out_ent = TransactionEntity.objects.create(name="OutEnt")
in_ent = TransactionEntity.objects.create(name="InEnt")
from_t, to_t = self._transfer(
from_category=str(out_cat.id),
to_category=str(in_cat.id),
from_tags=[out_tag.name],
to_tags=[in_tag.name],
from_entities=[out_ent.name],
to_entities=[in_ent.name],
)
self.assertEqual(list(from_t.entities.all()), [out_ent])
self.assertEqual(list(to_t.entities.all()), [in_ent])
response = self.client.get(
reverse("monthly_transactions_list", kwargs={"month": 3, "year": 2026}),
HTTP_HX_REQUEST="true",
)
content = response.content.decode()
for name in ("OutCat", "InCat", "OutTag", "InTag"):
for name in ("OutCat", "InCat", "OutTag", "InTag", "OutEnt", "InEnt"):
self.assertIn(name, content)
def test_edit_keeps_inactive_tag_and_archived_account(self):
tag = TransactionTag.objects.create(name="OldTag")
from_t, to_t = self._transfer(from_tags=[tag.name])
tag.active = False
tag.save()
self.savings.is_archived = True
self.savings.save()
form = TransferForm(
{
"from_account": self.checking.id,
"to_account": self.savings.id,
"from_amount": "100",
"date": "2026-03-10",
"from_tags": [tag.name],
},
instance=from_t,
)
self.assertTrue(form.is_valid(), form.errors)
form.save()
self.assertEqual(list(from_t.tags.all()), [tag])
class TransferTemplateTests(TransferTestBase):
"""Recurring and quick transactions of type Transfer."""
@@ -76,6 +76,16 @@
<div class="flex-1 ps-0">{{ overriden_entities|join:", " }}</div>
</div>
{% elif is_transfer %}
{% if transaction.entities.all or transfer_to.entities.all %}
<div class="grid grid-cols-[auto_1fr] mb-2 lg:mb-1 text-base-content/70">
<div class="w-auto pe-1"><i class="fa-solid fa-user-group fa-fw mr-1 fa-xs"></i></div>
<div>{{ transaction.entities.all|join:", "|default:"—" }}</div>
<div class="w-auto pe-1"><i class="fa-solid fa-arrow-right fa-fw mr-1 fa-xs text-base-content/40"></i></div>
<div>{{ transfer_to.entities.all|join:", "|default:"—" }}</div>
</div>
{% endif %}
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
{% elif transaction.id and transaction.entities.all %}
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">