diff --git a/cl/search/tests/tests_es_export.py b/cl/search/tests/tests_es_export.py new file mode 100644 index 0000000000..6acf9c3b12 --- /dev/null +++ b/cl/search/tests/tests_es_export.py @@ -0,0 +1,118 @@ +from django.core import mail +from django.core.management import call_command +from django.http import QueryDict +from django.urls import reverse + +from cl.lib.search_utils import fetch_es_results_for_csv +from cl.lib.test_helpers import RECAPSearchTestCase +from cl.search.models import SEARCH_TYPES +from cl.tests.cases import ESIndexTestCase, TestCase +from cl.users.factories import UserProfileWithParentsFactory + + +class ExportSearchTest(RECAPSearchTestCase, ESIndexTestCase, TestCase): + + errors = [ + ("Unbalance Quotes", 'q="test&type=o'), + ("Unbalance Parentheses", "q=Leave)&type=o"), + ("Bad syntax", "q=Leave /:&type=o"), + ] + + @classmethod + def setUpTestData(cls): + cls.user_profile = UserProfileWithParentsFactory() + cls.rebuild_index("search.Docket") + super().setUpTestData() + cls.rebuild_index("people_db.Person") + call_command( + "cl_index_parent_and_child_docs", + search_type=SEARCH_TYPES.RECAP, + queue="celery", + pk_offset=0, + testing_mode=True, + ) + + def test_returns_empty_list_when_query_with_error(self) -> None: + """Confirms the search helper returns an empty list when provided with + invalid query parameters.""" + for description, query in self.errors: + with self.subTest(description): + results = fetch_es_results_for_csv( + QueryDict(query.encode(), mutable=True), + SEARCH_TYPES.OPINION, + ) + self.assertEqual(len(results), 0) + + def test_limit_number_of_search_results(self) -> None: + """Checks hat `fetch_es_results_for_csv` returns a list with a size + equal to MAX_SEARCH_RESULTS_EXPORTED or the actual number of search + results (if it's less than `MAX_SEARCH_RESULTS_EXPORTED`) + """ + # This query should match all 5 judges indexed for this test + query = "q=gender:Female&type=p" + for i in range(6): + with self.subTest( + f"try to fetch only {i+1} results" + ), self.settings(MAX_SEARCH_RESULTS_EXPORTED=i + 1): + results = fetch_es_results_for_csv( + QueryDict(query.encode(), mutable=True), + SEARCH_TYPES.PEOPLE, + ) + expected_result_count = min( + i + 1, 5 + ) # Cap at 5 (total matching results) + self.assertEqual(len(results), expected_result_count) + + def test_can_flatten_nested_results(self) -> None: + """checks `fetch_es_results_for_csv` correctly handles and flattens + nested results.""" + # this query should match both docket records indexed + query = "type=r&q=12-1235 OR Jackson" + results = fetch_es_results_for_csv( + QueryDict(query.encode(), mutable=True), SEARCH_TYPES.RECAP + ) + # We expect 3 results because: + # - Docket 21-bk-1234 has 2 associated documents. + # - Docket 12-1235 has 1 associated document. + # + # The `fetch_es_results_for_csv` helper function should: + # - Flatten the results. + # - Add a row for each child document. + self.assertEqual(len(results), 3) + + def test_avoids_sending_email_for_query_with_error(self) -> None: + "Confirms we don't send emails when provided with invalid query" + self.client.login( + username=self.user_profile.user.username, password="password" + ) + for description, query in self.errors: + with self.subTest(description): + self.client.post( + reverse("export_search_results"), {"query": query} + ) + self.assertEqual(len(mail.outbox), 0) + + def test_do_not_send_empty_emails(self) -> None: + """Confirms that no emails are sent when the search query returns no + results""" + self.client.login( + username=self.user_profile.user.username, password="password" + ) + self.client.post( + reverse("export_search_results"), {"query": 'q="word"&type=r'} + ) + self.assertEqual(len(mail.outbox), 0) + + def test_sends_email_with_attachment(self) -> None: + "Confirms we dont send emails when provided with invalid query" + self.client.login( + username=self.user_profile.user.username, password="password" + ) + self.client.post( + reverse("export_search_results"), {"query": 'q="Jackson"&type=r'} + ) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].subject, "Your Search Results are Ready!" + ) + self.assertEqual(mail.outbox[0].to[0], self.user_profile.user.email) diff --git a/cl/search/urls.py b/cl/search/urls.py index c1e7b9033a..69c7ee1d00 100644 --- a/cl/search/urls.py +++ b/cl/search/urls.py @@ -6,11 +6,19 @@ SearchFeed, search_feed_error_handler, ) -from cl.search.views import advanced, es_search, show_results +from cl.search.views import ( + advanced, + es_search, + export_search_results, + show_results, +) urlpatterns = [ # Search pages path("", show_results, name="show_results"), + path( + "search/export/", export_search_results, name="export_search_results" + ), path("opinion/", advanced, name="advanced_o"), path("audio/", advanced, name="advanced_oa"), path("person/", advanced, name="advanced_p"), diff --git a/cl/search/views.py b/cl/search/views.py index 10f28fd880..a29ab55065 100644 --- a/cl/search/views.py +++ b/cl/search/views.py @@ -4,6 +4,7 @@ from asgiref.sync import async_to_sync from cache_memoize import cache_memoize from django.contrib import messages +from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.db.models import Count, Sum from django.http import HttpRequest, HttpResponse @@ -12,6 +13,7 @@ from django.urls import reverse from django.utils.timezone import make_aware from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_POST from waffle.decorators import waffle_flag from cl.alerts.forms import CreateAlertForm @@ -20,6 +22,7 @@ from cl.custom_filters.templatetags.text_filters import naturalduration from cl.lib.bot_detector import is_bot from cl.lib.elasticsearch_utils import get_only_status_facets +from cl.lib.ratelimiter import ratelimiter_unsafe_5_per_d from cl.lib.redis_utils import get_redis_interface from cl.lib.search_utils import ( do_es_search, @@ -27,9 +30,11 @@ merge_form_with_courts, store_search_query, ) +from cl.lib.types import AuthenticatedHttpRequest from cl.search.documents import OpinionClusterDocument from cl.search.forms import SearchForm, _clean_form from cl.search.models import SEARCH_TYPES, Court, Opinion +from cl.search.tasks import email_search_results from cl.stats.models import Stat from cl.stats.utils import tally_stat from cl.visualizations.models import SCOTUSMap @@ -361,3 +366,13 @@ def es_search(request: HttpRequest) -> HttpResponse: ) return render(request, template, render_dict) + + +@login_required +@ratelimiter_unsafe_5_per_d +@require_POST +def export_search_results(request: AuthenticatedHttpRequest) -> HttpResponse: + email_search_results.delay(request.user.pk, request.POST.get("query", "")) + # TODO: Update the frontend using Htmx to show a message indicating the + # export of search results is in progress. + return HttpResponse("It worked.")