Skip to content

Commit 4d2254a

Browse files
committed
feat(crm): add invoice list search
Make CRM invoice lists searchable by invoice number, customer details, and line items so staff can find invoices faster.
1 parent 24af9ed commit 4d2254a

4 files changed

Lines changed: 147 additions & 10 deletions

File tree

weblate_web/crm/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def clean(self):
4848
return self.cleaned_data
4949

5050

51-
class CustomerSearchForm(forms.Form):
51+
class CRMSearchForm(forms.Form):
5252
q = forms.CharField(
5353
label=gettext_lazy("Search"),
5454
required=False,

weblate_web/crm/templates/invoices/invoice_list.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
{% load i18n %}
44

55
{% block content %}
6+
{% include "crm/snippets/search_form.html" %}
7+
68
<section class="crm-section">
79
{% translate "Invoice list" as table_title %}
810
{% include "crm/snippets/section_header.html" with section_title=table_title only %}

weblate_web/crm/tests.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,117 @@ def test_transient_vies_interactions_are_deleted(self) -> None:
221221
)
222222

223223

224+
class CRMInvoiceSearchTestCase(BaseCRMTestCase):
225+
user: User
226+
227+
def setUp(self):
228+
self.user = User.objects.create_superuser(
229+
username="admin", email="admin@example.com"
230+
)
231+
self.client.force_login(self.user)
232+
233+
def create_search_invoice(
234+
self,
235+
*,
236+
customer_name: str,
237+
description: str,
238+
customer_email: str = "",
239+
kind: InvoiceKind = InvoiceKind.INVOICE,
240+
) -> Invoice:
241+
customer = self.create_customer(customer_name)
242+
if customer_email:
243+
customer.email = customer_email
244+
customer.save(update_fields=["email"])
245+
invoice = Invoice.objects.create(
246+
kind=kind,
247+
category=InvoiceCategory.HOSTING,
248+
customer=customer,
249+
currency=Currency.EUR,
250+
)
251+
invoice.invoiceitem_set.create(
252+
description=description, quantity=1, unit_price=Decimal(100)
253+
)
254+
invoice.refresh_from_db()
255+
return invoice
256+
257+
def test_invoice_search(self):
258+
invoice = self.create_search_invoice(
259+
customer_name="Acme Search Customer",
260+
customer_email="billing-search@example.com",
261+
description="Hosted invoice search item",
262+
)
263+
other_invoice = self.create_search_invoice(
264+
customer_name="Other Customer",
265+
customer_email="other@example.com",
266+
description="Consulting invoice item",
267+
)
268+
269+
list_url = reverse("crm:invoice-list", kwargs={"kind": "all"})
270+
response = self.client.get(list_url)
271+
self.assertContains(response, invoice.number)
272+
self.assertContains(response, other_invoice.number)
273+
self.assertContains(response, 'type="search"')
274+
275+
searches = (
276+
invoice.number,
277+
"acme search",
278+
"billing-search@example.com",
279+
"hosted invoice search",
280+
)
281+
for query in searches:
282+
with self.subTest(query=query):
283+
response = self.client.get(list_url, {"q": query})
284+
285+
self.assertEqual(response.context["query"], query)
286+
self.assertContains(response, invoice.number)
287+
self.assertNotContains(response, other_invoice.number)
288+
289+
def test_invoice_search_deduplicates_line_item_matches(self):
290+
invoice = self.create_search_invoice(
291+
customer_name="Repeated Item Customer",
292+
description="Repeated support item",
293+
)
294+
invoice.invoiceitem_set.create(
295+
description="Repeated support item follow-up",
296+
quantity=1,
297+
unit_price=Decimal(50),
298+
)
299+
300+
response = self.client.get(
301+
reverse("crm:invoice-list", kwargs={"kind": "all"}),
302+
{"q": "Repeated support item"},
303+
)
304+
305+
self.assertContains(response, invoice.number, count=1)
306+
307+
def test_invoice_search_keeps_kind_filter(self):
308+
invoice = self.create_search_invoice(
309+
customer_name="Shared Search Customer",
310+
description="Shared search invoice",
311+
)
312+
quote = self.create_search_invoice(
313+
customer_name="Shared Search Customer",
314+
description="Shared search quote",
315+
kind=InvoiceKind.QUOTE,
316+
)
317+
318+
response = self.client.get(
319+
reverse("crm:invoice-list", kwargs={"kind": "quote"}),
320+
{"q": "Shared Search Customer"},
321+
)
322+
323+
self.assertContains(response, quote.number)
324+
self.assertNotContains(response, invoice.number)
325+
326+
response = self.client.get(
327+
reverse("crm:invoice-list", kwargs={"kind": "invoice"}),
328+
{"q": "Shared Search Customer"},
329+
)
330+
331+
self.assertContains(response, invoice.number)
332+
self.assertNotContains(response, quote.number)
333+
334+
224335
class CRMTestCase(BaseCRMTestCase):
225336
user: User
226337

weblate_web/crm/views.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
from django.views.generic import DetailView, ListView, TemplateView
4040

4141
from weblate_web.crm.forms import (
42+
CRMSearchForm,
4243
CustomerMergeForm,
43-
CustomerSearchForm,
4444
CustomerUserForm,
4545
InvoiceConfirmationForm,
4646
ManualInteractionForm,
@@ -340,6 +340,7 @@ class InvoiceListView(CRMMixin, ListView[Invoice]): # type: ignore[misc]
340340
permission = "invoices.view_invoice"
341341
title = "Invoices"
342342
paginate_by = 100
343+
_search_form: CRMSearchForm | None = None
343344

344345
def get_title(self) -> str:
345346
match self.kwargs["kind"]:
@@ -353,22 +354,45 @@ def get_title(self) -> str:
353354
return "Invoices"
354355
raise ValueError(self.kwargs["kind"])
355356

357+
def get_search_form(self) -> CRMSearchForm:
358+
if self._search_form is None:
359+
self._search_form = CRMSearchForm(self.request.GET)
360+
return self._search_form
361+
362+
def get_context_data(self, **kwargs):
363+
context = super().get_context_data(**kwargs) # type:ignore[misc]
364+
search_form = self.get_search_form()
365+
context["search_form"] = search_form
366+
context["query"] = search_form["q"].value() or ""
367+
return context
368+
356369
def get_queryset(self):
357370
qs = super().get_queryset().order_by("-number")
358371
match self.kwargs["kind"]:
359372
case "unpaid":
360-
return qs.filter(
373+
qs = qs.filter(
361374
Q(paid_payment_set__state__in={Payment.NEW, Payment.PENDING})
362375
| Q(paid_payment_set=None),
363376
kind=InvoiceKind.INVOICE,
364377
)
365378
case "quote":
366-
return qs.filter(kind=InvoiceKind.QUOTE)
379+
qs = qs.filter(kind=InvoiceKind.QUOTE)
367380
case "invoice":
368-
return qs.filter(kind=InvoiceKind.INVOICE)
381+
qs = qs.filter(kind=InvoiceKind.INVOICE)
369382
case "all":
370-
return qs
371-
raise ValueError(self.kwargs["kind"])
383+
pass
384+
case _:
385+
raise ValueError(self.kwargs["kind"])
386+
387+
search_form = self.get_search_form()
388+
if search_form.is_valid() and (query := search_form.cleaned_data["q"]):
389+
qs = qs.filter(
390+
Q(number__icontains=query)
391+
| Q(customer__name__icontains=query)
392+
| Q(customer__email__icontains=query)
393+
| Q(invoiceitem__description__icontains=query)
394+
).distinct()
395+
return qs
372396

373397

374398
class InvoiceDetailView(CRMMixin, DetailView[Invoice]): # type: ignore[misc]
@@ -510,7 +534,7 @@ class CustomerListView(CRMMixin, ListView[Customer]): # type: ignore[misc]
510534
permission = "payments.view_customer"
511535
title = "Customers"
512536
paginate_by = 100
513-
_search_form: CustomerSearchForm | None = None
537+
_search_form: CRMSearchForm | None = None
514538

515539
def get_title(self) -> str:
516540
match self.kwargs["kind"]:
@@ -520,9 +544,9 @@ def get_title(self) -> str:
520544
return "All customers"
521545
raise ValueError(self.kwargs["kind"])
522546

523-
def get_search_form(self) -> CustomerSearchForm:
547+
def get_search_form(self) -> CRMSearchForm:
524548
if self._search_form is None:
525-
self._search_form = CustomerSearchForm(self.request.GET)
549+
self._search_form = CRMSearchForm(self.request.GET)
526550
return self._search_form
527551

528552
def get_context_data(self, **kwargs):

0 commit comments

Comments
 (0)