diff --git a/app/apps/transactions/tests/test_transaction_search.py b/app/apps/transactions/tests/test_transaction_search.py new file mode 100644 index 00000000..6b02e95b --- /dev/null +++ b/app/apps/transactions/tests/test_transaction_search.py @@ -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"), []) diff --git a/app/apps/transactions/utils/select_search.py b/app/apps/transactions/utils/select_search.py index 41b6f6c2..161383a5 100644 --- a/app/apps/transactions/utils/select_search.py +++ b/app/apps/transactions/utils/select_search.py @@ -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")) diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index 1b610ea3..fa2f0207 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -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 = (