feat(transactions): search transactions by date, amount, entity and account

Each word of the query must match something, and words can match
different fields: description, notes, category, tags, entities, account
and account group, a date in the user's format (the year is optional),
or an amount in the user's number format, where a whole number like 234
matches 234.00 to 234.99.

Matching through an id subquery also stops transactions with several
matching tags from showing up more than once.
This commit is contained in:
Herculino Trotta committed 2026-11-08 03:27:21 -03:00
1 parent 8482c85a76
commit 9f9146aaec
3 files changed
+144 -8

No files matched your search

@@ -0,0 +1,74 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionEntity,
TransactionTag,
)
class TransactionSearchTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="search@test.com", password="pass"
)
self.client.login(username="search@test.com", password="pass")
self.user.settings.date_format = "d/m/Y"
self.user.settings.number_format = "DC"
self.user.settings.save()
currency = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
self.account = Account.objects.create(name="Wallet", currency=currency)
self.today = timezone.localdate(timezone.now())
def make(self, days, description="", amount="10", **kwargs):
return Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=self.today + timedelta(days=days),
reference_date=(self.today + timedelta(days=days)).replace(day=1),
amount=Decimal(amount),
description=description,
**kwargs,
)
def search(self, q=""):
response = self.client.get(reverse("transactions_search"), {"q": q})
self.assertEqual(response.status_code, 200)
return [row["description"] for row in response.json()]
def test_order_matches_monthly_view(self):
for days, name in [(-30, "old"), (-2, "recent"), (-1, "yesterday"),
(10, "far"), (2, "soon"), (1, "tomorrow"), (0, "today")]:
self.make(days, name)
self.assertEqual(
self.search(),
["today", "tomorrow", "yesterday", "soon", "far", "recent", "old"],
)
def test_search_fields(self):
t = self.make(-5, "Groceries", amount="1234.56")
t.category = TransactionCategory.objects.create(name="Food")
t.save()
t.tags.add(TransactionTag.objects.create(name="home"),
TransactionTag.objects.create(name="weekly"))
t.entities.add(TransactionEntity.objects.create(name="Market"))
self.make(-6, "Other", amount="5")
day = (self.today - timedelta(days=5))
for q in ["food", "market", "1.234,56", "1234",
day.strftime("%d/%m/%Y"), day.strftime("%d/%m"),
"home weekly", "groceries food"]:
self.assertEqual(self.search(q), ["Groceries"], q)
self.assertEqual(self.search("wallet"), ["Groceries", "Other"])
self.assertEqual(self.search("groceries nomatch"), [])
@@ -1,4 +1,11 @@
import datetime
import re
from decimal import Decimal, InvalidOperation
from apps.common.functions.format import get_format
from apps.common.utils.django import django_to_python_datetime
from apps.transactions.templatetags.currency_display import currency_display
from django.db.models import Q
from django.template.defaultfilters import date as date_filter
@@ -28,3 +35,61 @@ def transaction_option(t):
"tags": [tag.name for tag in t.tags.all()],
"entities": [entity.name for entity in t.entities.all()],
}
def _parse_date(term):
"""Match a term against the user's date format; without a year it matches any year."""
fmt = django_to_python_datetime(get_format("SHORT_DATE_FORMAT"))
try:
return Q(date=datetime.datetime.strptime(term, fmt).date())
except ValueError:
pass
# Drop the year and its adjacent separator, e.g. "%d/%m/%Y" -> "%d/%m"
no_year = re.sub(r"[^%\w]?%[Yy][^%\w]?", "", fmt, count=1).strip()
if no_year == fmt or not no_year:
return None
try:
# 2000 is a leap year, so 29/02 parses
parsed = datetime.datetime.strptime(f"{term} 2000", f"{no_year} %Y")
except ValueError:
return None
return Q(date__day=parsed.day, date__month=parsed.month)
def _parse_amount(term):
if not re.fullmatch(r"[\d.,\s]*\d[\d.,\s]*", term):
return None
thousand = get_format("THOUSAND_SEPARATOR")
decimal = get_format("DECIMAL_SEPARATOR")
normalized = term.replace(thousand, "").replace(decimal, ".")
try:
value = Decimal(normalized)
except InvalidOperation:
return None
if decimal not in term:
# Whole number: "12" finds 12.00 to 12.99
return Q(amount__gte=value, amount__lt=value + 1)
return Q(amount=value)
def search_transactions(queryset, search_term):
"""Every word must match some field (description, category, entity, date, amount...)."""
matches = queryset.model.objects.all()
for term in search_term.split():
term_q = (
Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(internal_note__icontains=term)
| Q(tags__name__icontains=term)
| Q(entities__name__icontains=term)
| Q(category__name__icontains=term)
| Q(account__name__icontains=term)
| Q(account__group__name__icontains=term)
)
for extra in (_parse_date(term), _parse_amount(term)):
if extra is not None:
term_q |= extra
# One filter() per word so different words can match different tags/entities
matches = matches.filter(term_q)
# id__in avoids duplicate rows from the tag/entity joins
return queryset.filter(id__in=matches.values("id"))
+5 -8
View File
@@ -20,7 +20,10 @@ from apps.transactions.utils.calculations import (
calculate_percentage_distribution,
)
from apps.transactions.utils.default_ordering import default_order
from apps.transactions.utils.select_search import transaction_option
from apps.transactions.utils.select_search import (
search_transactions,
transaction_option,
)
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
@@ -987,13 +990,7 @@ def get_recent_transactions(request, filter_type=None):
queryset = queryset.filter(type=Transaction.Type.INCOME)
if search_term:
queryset = queryset.filter(
Q(description__icontains=search_term)
| Q(notes__icontains=search_term)
| Q(internal_note__icontains=search_term)
| Q(tags__name__icontains=search_term)
| Q(category__name__icontains=search_term)
)
queryset = search_transactions(queryset, search_term)
# Today, tomorrow, yesterday, then upcoming (soonest first), then past (newest first)
queryset = (