Skip to content
This repository was archived by the owner on Aug 29, 2026. It is now read-only.

Commit 1830651

Browse files
committed
feat: Add advanced search
1 parent 93ac791 commit 1830651

8 files changed

Lines changed: 213 additions & 13 deletions

File tree

pdfding/e2e/test_pdf_overview_e2e.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.test import override_settings
66
from django.urls import reverse
77
from helpers import PdfDingE2ETestCase
8-
from pdf.models.pdf_models import Pdf
8+
from pdf.models.pdf_models import Metadata, Pdf
99
from pdf.models.tag_models import Tag
1010
from playwright.sync_api import expect, sync_playwright
1111
from users.models import Profile
@@ -502,6 +502,23 @@ def test_search_filters(self):
502502
expect(self.page.locator("#search_filter")).not_to_be_visible()
503503
expect(self.page.locator("#tag_tag_filter")).not_to_be_visible()
504504

505+
def test_advanced_search(self):
506+
pdf = Pdf.objects.get(name='pdf_1_1')
507+
Metadata.objects.create(pdf=pdf, keywords='some_keyword')
508+
509+
with sync_playwright() as p:
510+
self.open(reverse('advanced_search'), p)
511+
512+
self.page.locator("#id_name").click()
513+
self.page.locator("#id_name").fill("pdf")
514+
self.page.locator("#id_keywords").click()
515+
self.page.locator("#id_keywords").fill("some_keyword")
516+
self.page.get_by_role("button", name="Search").click()
517+
518+
# assert there is only one pdf matching the search
519+
expect(self.page.locator("#pdf-link-1")).to_contain_text("pdf_1_1")
520+
expect(self.page.locator("#pdf-link-2")).not_to_be_visible()
521+
505522
def test_sort(self):
506523
self.user.profile.pdf_sorting = Profile.PdfSortingChoice.MOST_VIEWED
507524
self.user.profile.save()

pdfding/pdf/forms.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,38 @@ class Meta:
623623
fields = ['description']
624624

625625

626+
class AdvancedSearchForm(forms.Form):
627+
"""Form for the advanced search."""
628+
629+
# normal fields
630+
name = forms.CharField(required=False)
631+
name.group = 'PDF Details'
632+
tags = forms.CharField(required=False)
633+
tags.group = 'PDF Details'
634+
description = forms.CharField(required=False)
635+
description.group = 'PDF Details'
636+
notes = forms.CharField(required=False)
637+
notes.group = 'PDF Details'
638+
639+
# metadata fields
640+
title = forms.CharField(required=False)
641+
title.group = 'PDF Metadata'
642+
author = forms.CharField(required=False)
643+
author.group = 'PDF Metadata'
644+
abstract = forms.CharField(required=False)
645+
abstract.group = 'PDF Metadata'
646+
keywords = forms.CharField(required=False)
647+
keywords.group = 'PDF Metadata'
648+
doi = forms.CharField(required=False, label='DOI')
649+
doi.group = 'PDF Metadata'
650+
journal = forms.CharField(required=False)
651+
journal.group = 'PDF Metadata'
652+
publisher = forms.CharField(required=False)
653+
publisher.group = 'PDF Metadata'
654+
year = forms.CharField(required=False)
655+
year.group = 'PDF Metadata'
656+
657+
626658
class CleanHelpers:
627659
@staticmethod
628660
def clean_file(file: File) -> File:
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{% extends 'layouts/add_box.html' %}
2+
{% load i18n %}
3+
4+
{% block content %}
5+
6+
<h1>{% trans 'Advanced Search' %}</h1>
7+
8+
<form action="{% url 'advanced_search_query' %}" method="GET" id="advanced_search_form">
9+
<div class="flex flex-col pb-4 gap-y-2">
10+
{% regroup form by field.group as field_groups %}
11+
{% for field_group in field_groups %}
12+
<div class="text-2xl font-bold {% if field_group.grouper == 'PDF Metadata' %}pt-4{% endif %}">
13+
{{ field_group.grouper }}
14+
</div>
15+
<div class="gap-y-2 md:grid md:grid-cols-2 md:gap-x-10">
16+
{% for field in field_group.list %}
17+
<div>
18+
{{ field.errors }}
19+
<div class="[&>input]:rounded-md! [&>input]:py-2!">
20+
<span class="font-semibold">{{ field.label }}</span>
21+
{{ field }}
22+
</div>
23+
</div>
24+
{% endfor %}
25+
</div>
26+
{% endfor %}
27+
</div>
28+
<button type="submit" >{% trans 'Search' %}</button>
29+
</form>
30+
31+
{% endblock %}

pdfding/pdf/templates/includes/pdf_overview/header_extra.html

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
{% load i18n %}
22
<div x-data="{ sortOpen: false, layoutOpen: false }" class="relative flex flex-row grow justify-end items-center md:gap-x-1">
3-
<form class="flex grow md:max-w-120 lg:max-w-160 px-3 md:px-5" action="{% url 'pdf_overview_query' %}" method="GET" id="search_form"> <input class="border h-10 rounded-sm! align-text-center focus:border-primary!
4-
bg-slate-100 dark:bg-slate-800! border-slate-400 dark:border-slate-600"
5-
type="text" placeholder="{% translate 'Search for PDFs or #tags' %}"
6-
id="search_input"
7-
name="search"
8-
@keyup.enter="document.getElementById('search_form').submit()"
3+
<form class="flex grow md:max-w-120 lg:max-w-160 px-3 md:px-5" action="{% url 'pdf_overview_query' %}" method="GET" id="search_form">
4+
<input class="border h-10 rounded-sm! align-text-center focus:border-primary!
5+
bg-slate-100 dark:bg-slate-800! border-slate-400 dark:border-slate-600"
6+
type="text" placeholder="{% translate 'Search for PDFs or #tags' %}"
7+
id="search_input"
8+
name="search"
9+
@keyup.enter="document.getElementById('search_form').submit()"
910
/>
1011
</form>
1112
<a id="bulk_edit"

pdfding/pdf/templates/pdf_overview.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
md:ml-10 min-[850px]:ml-10! lg:mx-10!
2424
{% endif %}
2525
w-full pb-4">
26-
{% if search_query or tag_query %}
26+
{% if advanced_search %}
27+
{% querystring %}
28+
{% elif search_query or tag_query %}
2729
{% include 'includes/pdf_overview/search_filters.html' %}
2830
{% endif %}
2931
{% if not page_obj %}
3032
<div class="pt-4 *:flex *:justify-center">
31-
{% if search_query or tag_query or special_pdf_selection %}
33+
{% if search_query or tag_query or special_pdf_selection or advanced_search %}
3234
<span class="text-2xl">{% trans "There aren't any PDFs matching the current filters" %}</span>
3335
<span class="pt-4 text-lg">{% trans 'Please try different filters' %}.</span>
3436
{% else %}

pdfding/pdf/tests/test_views/test_pdf_views.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ def setUp(self):
381381
self.user = None
382382
set_up(self)
383383

384-
def test_filter_objects(self):
384+
@patch('pdf.views.pdf_views.OverviewMixin.advanced_search_filtering')
385+
def test_filter_objects(self, mock_advanced_filtering):
385386
# create some pdfs
386387
for i in range(1, 15):
387388
pdf = Pdf.objects.create(collection=self.user.profile.current_collection, name=f'pdf_{i % 5}_{i}')
@@ -404,11 +405,14 @@ def test_filter_objects(self):
404405
pdf_2.tags.set(tags[2:3])
405406
pdf_3.tags.set(tags)
406407

407-
response = self.client.get(f'{reverse('pdf_overview')}?search=pdf_&tags=programming/python')
408+
response = self.client.get(
409+
f'{reverse('pdf_overview')}?search=pdf_&tags=programming/python&advanced_search=false'
410+
)
408411

409412
filtered_pdfs = pdf_views.OverviewMixin.filter_objects(response.wsgi_request)
410413

411414
self.assertEqual(sorted(list(filtered_pdfs), key=lambda a: a.name), [pdf_1, pdf_2])
415+
mock_advanced_filtering.assert_not_called()
412416

413417
def test_filter_objects_starred(self):
414418
pdf_1 = Pdf.objects.create(
@@ -463,6 +467,18 @@ def test_filter_objects_archived(self):
463467

464468
self.assertEqual(list(filtered_pdfs), [pdf_1])
465469

470+
@patch('pdf.views.pdf_views.OverviewMixin.advanced_search_filtering')
471+
def test_filter_objects_advanced(self, mock_advanced_filtering):
472+
pdf_1 = Pdf.objects.create(collection=self.user.profile.current_collection, name='pdf_to_be_found')
473+
Pdf.objects.create(collection=self.user.profile.current_collection, name='pdf_not_to_be_found')
474+
475+
mock_advanced_filtering.return_value = self.user.profile.current_pdfs.filter(name='pdf_to_be_found')
476+
response = self.client.get(f'{reverse('pdf_overview')}?advanced_search=true')
477+
478+
filtered_pdfs = pdf_views.OverviewMixin.filter_objects(response.wsgi_request)
479+
assert filtered_pdfs.count() == 1
480+
assert filtered_pdfs.first() == pdf_1
481+
466482
def test_fuzzy_filter_pdfs(self):
467483
Pdf.objects.create(collection=self.user.profile.current_collection, name='pdf_not_to_be_found')
468484
pdf_self_hosted = Pdf.objects.create(
@@ -476,12 +492,32 @@ def test_fuzzy_filter_pdfs(self):
476492
filtered_pdfs = pdf_views.OverviewMixin.fuzzy_filter_pdfs(Pdf.objects.all(), 'self hosted')
477493
self.assertEqual(sorted(list(filtered_pdfs), key=lambda a: a.name), [pdf_self_hosting, pdf_self_hosted])
478494

495+
def test_advanced_search_filtering(self):
496+
collection = self.user.profile.current_collection
497+
pdf_1 = Pdf.objects.create(collection=collection, name='eggplant')
498+
pdf_2 = Pdf.objects.create(collection=collection, name='eggplant_2', description='Vegetables')
499+
pdf_3 = Pdf.objects.create(collection=collection, name='eggplant_3', description='Vegetables')
500+
pdf_4 = Pdf.objects.create(collection=collection, name='tomato')
501+
Pdf.objects.create(collection=collection, name='tomato_2', description='Vegetables')
502+
Metadata.objects.create(pdf=pdf_1, title='Eggplant', year=2016)
503+
Metadata.objects.create(pdf=pdf_2, title='All about vegetables: Eggplant', year=2016)
504+
Metadata.objects.create(pdf=pdf_3, title='All about vegetables: Tomato', year=2017)
505+
Metadata.objects.create(pdf=pdf_4, title='Tomato', year=2017)
506+
507+
filtered_pdfs = pdf_views.OverviewMixin.advanced_search_filtering(
508+
Pdf.objects.all(), {'description': 'vegetable', 'title': 'About', 'year': 2016}
509+
)
510+
511+
assert filtered_pdfs.count() == 1
512+
assert filtered_pdfs.first() == pdf_2
513+
479514
@patch('pdf.services.tag_services.TagServices.get_tag_info_dict', return_value='tag_info_dict')
480515
def test_get_extra_context(self, mock_get_tag_info_dict):
481-
response = self.client.get(f'{reverse('pdf_overview')}?search=searching&tags=tagging')
516+
response = self.client.get(f'{reverse('pdf_overview')}?search=searching&tags=tagging&advanced_search=false')
482517

483518
generated_extra_context = pdf_views.OverviewMixin.get_extra_context(response.wsgi_request)
484519
expected_extra_context = {
520+
'advanced_search': False,
485521
'search_query': 'searching',
486522
'tag_query': ['tagging'],
487523
'tag_info_dict': 'tag_info_dict',
@@ -501,6 +537,7 @@ def test_get_extra_context_selection(self, mock_get_tag_info_dict):
501537

502538
generated_extra_context = pdf_views.OverviewMixin.get_extra_context(response.wsgi_request)
503539
expected_extra_context = {
540+
'advanced_search': False,
504541
'search_query': '',
505542
'tag_query': [],
506543
'tag_info_dict': 'tag_info_dict',
@@ -514,12 +551,20 @@ def test_get_extra_context_selection(self, mock_get_tag_info_dict):
514551

515552
self.assertEqual(generated_extra_context, expected_extra_context)
516553

554+
@patch('pdf.services.tag_services.TagServices.get_tag_info_dict', return_value='tag_info_dict')
555+
def test_get_extra_context_advanced_search_true(self, mock_get_tag_info_dict):
556+
response = self.client.get(f'{reverse('pdf_overview')}?advanced_search=true')
557+
558+
generated_extra_context = pdf_views.OverviewMixin.get_extra_context(response.wsgi_request)
559+
assert generated_extra_context['advanced_search']
560+
517561
@patch('pdf.services.tag_services.TagServices.get_tag_info_dict', return_value='tag_info_dict')
518562
def test_get_extra_context_selection_invalid(self, mock_get_tag_info_dict):
519563
response = self.client.get(f'{reverse('pdf_overview')}?selection=invalid')
520564

521565
generated_extra_context = pdf_views.OverviewMixin.get_extra_context(response.wsgi_request)
522566
expected_extra_context = {
567+
'advanced_search': False,
523568
'search_query': '',
524569
'tag_query': [],
525570
'tag_info_dict': 'tag_info_dict',
@@ -539,6 +584,7 @@ def test_get_extra_context_empty_queries(self, mock_get_tag_info_dict):
539584

540585
generated_extra_context = pdf_views.OverviewMixin.get_extra_context(response.wsgi_request)
541586
expected_extra_context = {
587+
'advanced_search': False,
542588
'search_query': '',
543589
'tag_query': [],
544590
'tag_info_dict': 'tag_info_dict',
@@ -1035,6 +1081,17 @@ def test_export_annotations_without_identifier(self, mock_export_annotations):
10351081

10361082
mock_export_annotations.assert_called_once_with(self.user.profile, 'highlights')
10371083

1084+
def test_advanced_search_query(self):
1085+
queries = (
1086+
'?name=some_name&tags=&description=some_description&title=The+Title&doi=&journal=&publisher=&year=2017'
1087+
)
1088+
response = self.client.get(f'{reverse("advanced_search_query")}{queries}')
1089+
1090+
expected_queries = (
1091+
'?description=some_description&title=The+Title&year=2017&search=some_name&advanced_search=true'
1092+
)
1093+
self.assertRedirects(response, f'{reverse('pdf_overview')}{expected_queries}', status_code=302)
1094+
10381095

10391096
class TestAnnotationMixin(TestCase):
10401097
username = 'user'

pdfding/pdf/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
# pdf related views
1010
path('', pdf_views.Overview.as_view(), name='pdf_overview'),
1111
path('query/', pdf_views.OverviewQuery.as_view(), name='pdf_overview_query'),
12+
path('advanced_search', pdf_views.AdvancedSearch.as_view(), name='advanced_search'),
13+
path('advanced_search_query', pdf_views.AdvancedSearchQuery.as_view(), name='advanced_search_query'),
1214
path('get_next_overview_page/<int:page>/', pdf_views.Overview.as_view(), name='get_next_pdf_overview_page'),
1315
path('add', pdf_views.Add.as_view(), name='add_pdf'),
1416
path('bulk_add', pdf_views.BulkAdd.as_view(), name='bulk_add_pdfs'),

pdfding/pdf/views/pdf_views.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.forms import Textarea, ValidationError
1010
from django.http import FileResponse, Http404, HttpRequest, HttpResponse
1111
from django.shortcuts import redirect, render
12+
from django.urls import reverse
1213
from django.utils.translation import gettext_lazy as _
1314
from django.views import View
1415
from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh
@@ -187,6 +188,9 @@ def filter_objects(cls, request: HttpRequest) -> QuerySet:
187188
if search:
188189
pdfs = cls.fuzzy_filter_pdfs(pdfs, search)
189190

191+
if request.GET.get('advanced_search', '') == 'true':
192+
pdfs = cls.advanced_search_filtering(pdfs, request.GET)
193+
190194
return pdfs
191195

192196
@staticmethod
@@ -205,6 +209,22 @@ def fuzzy_filter_pdfs(pdfs: QuerySet, search: str) -> QuerySet:
205209

206210
return pdfs
207211

212+
@staticmethod
213+
def advanced_search_filtering(pdfs: QuerySet, query_dict: dict) -> QuerySet:
214+
details_filter_dict = {
215+
f'{key}__icontains': value for key, value in query_dict.items() if key in ['description', 'notes']
216+
}
217+
metadata_filter_dict = {
218+
f'metadata__{key}__icontains': value
219+
for key, value in query_dict.items()
220+
if key in ['author', 'abstract', 'doi', 'keywords', 'journal', 'publisher', 'title', 'year']
221+
}
222+
223+
pdfs = pdfs.filter(**details_filter_dict)
224+
pdfs = pdfs.filter(**metadata_filter_dict)
225+
226+
return pdfs
227+
208228
@staticmethod
209229
def get_extra_context(request: HttpRequest) -> dict:
210230
"""get further information that needs to be passed to the template."""
@@ -221,12 +241,18 @@ def get_extra_context(request: HttpRequest) -> dict:
221241
special_pdf_selection = ''
222242
page = 'pdf_overview'
223243

244+
if request.GET.get('advanced_search', '') == 'true':
245+
advanced_search = True
246+
else:
247+
advanced_search = False
248+
224249
extra_context = {
225250
'layout': request.user.profile.layout,
226251
'page': page,
227-
'search_query': request.GET.get('search', ''),
228252
'special_pdf_selection': special_pdf_selection,
229253
'tag_info_dict': TagServices.get_tag_info_dict(request.user.profile),
254+
'advanced_search': advanced_search,
255+
'search_query': request.GET.get('search', ''),
230256
'tag_query': tag_query,
231257
'current_collection_id': request.user.profile.current_collection_id,
232258
'current_collection_name': request.user.profile.current_collection_name,
@@ -1015,3 +1041,35 @@ def get(self, request: HttpRequest, kind: str, identifier: str = ''):
10151041
response = FileResponse(annotations_buffer, as_attachment=True, filename='export.json')
10161042

10171043
return response
1044+
1045+
1046+
class AdvancedSearch(View):
1047+
"""View for the advanced PDF search."""
1048+
1049+
def get(self, request: HttpRequest): # pragma: no cover
1050+
"""Display the form for adding an object."""
1051+
1052+
context = {'form': forms.AdvancedSearchForm()}
1053+
1054+
return render(request, 'advanced_search.html', context)
1055+
1056+
1057+
class AdvancedSearchQuery(View):
1058+
"""View for the advanced PDF search query."""
1059+
1060+
def get(self, request: HttpRequest):
1061+
query_dict = dict(request.GET)
1062+
query_dict['search'] = query_dict.pop('name', '')
1063+
query_dict['advanced_search'] = ['true']
1064+
1065+
# If sep is not specified or is None, a different splitting algorithm is applied:
1066+
# runs of consecutive whitespace are regarded as a single separator
1067+
# a query item looks like this: key: ['value']
1068+
query_string = '&'.join(
1069+
f'{key}={"+".join(query[0].strip().split())}'
1070+
for key, query in query_dict.items()
1071+
if query not in [[], ['']]
1072+
)
1073+
redirect_url = f'{reverse('pdf_overview')}?{query_string}'
1074+
1075+
return redirect(redirect_url)

0 commit comments

Comments
 (0)