mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
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"))
|
||||
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user