Repository: mdn/kuma

Issue:
Feature regression: "Advanced" search no longer allows query-less search
Previously, using the advanced search functionality (through the special URLs documented on https://developer.mozilla.org/en-US/docs/MDN/Contribute/Tools/Search) did not require the `q=` term specifying a search term.

This allowed us to do things like use `css_classnames=` to find everywhere a given CSS is used in documentation content, `html_attributes=` to locate every use of a given HTML attribute, and `kumascript_macros=` to find every use of one of a set of one or more KumaScript macros.

These searches were often used during refactoring and cleanup work, as well as when doing style and layout improvements to suites of articles. When we need them, we usually need them pretty badly.

While I have no *immediate*, **urgent** need for these search abilities, when the time comes that I do, it will probably be pretty urgent, so I'm filing this now in hopes of getting a fix for this onto the schedule.


Discussion:
@peterbe is there a workaround for this? maybe a way to allow an explicit None or Null to the 'q' param, something like that?
> @peterbe is there a workaround for this? maybe a way to allow an explicit None or Null to the 'q' param, something like that?

Not that I know of, no. 

I don't think it'd be hard to enable searching on these things without a `q`. The code that checks that `q` is truthy just needs to be moved to the view that's used by https://developer.mozilla.org/en-US/search instead of https://developer.mozilla.org/api/v1/search/en-US

Resolving patch:
diff --git a/kuma/api/v1/views.py b/kuma/api/v1/views.py
--- a/kuma/api/v1/views.py
+++ b/kuma/api/v1/views.py
@@ -297,24 +297,11 @@ def filter_queryset(self, request, queryset, view):
         )
 
 
-class APISearchQueryBackend(SearchQueryBackend):
-    """Override of kuma.search.filters.SearchQueryBackend that makes a
-    stink if the 'q' query parameter is falsy."""
-
-    def filter_queryset(self, request, queryset, view):
-        search_term = (view.query_params.get("q") or "").strip()
-        if not search_term:
-            raise serializers.ValidationError({"error": "Search term 'q' must be set"})
-        return super(APISearchQueryBackend, self).filter_queryset(
-            request, queryset, view
-        )
-
-
 class APISearchView(SearchView):
     serializer_class = APIDocumentSerializer
     renderer_classes = [JSONRenderer]
     filter_backends = (
-        APISearchQueryBackend,
+        SearchQueryBackend,
         KeywordQueryBackend,
         TagGroupFilterBackend,
         APILanguageFilterBackend,
diff --git a/kuma/search/views.py b/kuma/search/views.py
--- a/kuma/search/views.py
+++ b/kuma/search/views.py
@@ -26,20 +26,26 @@ def search(request, *args, **kwargs):
     if is_wiki(request):
         return wiki_search(request, *args, **kwargs)
 
-    results = search_api(request, *args, **kwargs).data
-
-    # Determine if there were validation errors
-    error = results.get("error") or results.get("q")
-    # If q is returned in the data, there was a validation error for that field,
-    # so return 400 status.
-    status = 200 if results.get("q") is None else 400
-    # If there was an error with the pagination you'll get...
-    if results.get("detail"):
-        error = str(results["detail"])
+    # The underlying v1 API supports searching without a 'q' but the web
+    # UI doesn't. For example, the search input field requires a value.
+    # So we match that here too.
+    if not request.GET.get("q", "").strip():
         status = 400
+        context = {"results": {}}
+    else:
+        results = search_api(request, *args, **kwargs).data
 
-    context = {"results": {"results": None if error else results, "error": error}}
+        # Determine if there were validation errors
+        error = results.get("error") or results.get("q")
+        # If q is returned in the data, there was a validation error for that field,
+        # so return 400 status.
+        status = 200 if results.get("q") is None else 400
+        # If there was an error with the pagination you'll get...
+        if results.get("detail"):
+            error = str(results["detail"])
+            status = 400
 
+        context = {"results": {"results": None if error else results, "error": error}}
     return render(request, "search/react.html", context, status=status)
 
 


Test patch:
diff --git a/kuma/api/v1/tests/test_views.py b/kuma/api/v1/tests/test_views.py
--- a/kuma/api/v1/tests/test_views.py
+++ b/kuma/api/v1/tests/test_views.py
@@ -324,27 +324,17 @@ def test_whoami_is_subscriber(
 def test_search_validation_problems(user_client):
     url = reverse("api.v1.search", args=["en-US"])
 
-    # 'q' not present
-    response = user_client.get(url)
-    assert response.status_code == 400
-    assert response.json()["error"] == "Search term 'q' must be set"
-
-    # 'q' present but falsy
-    response = user_client.get(url, {"q": ""})
-    assert response.status_code == 400
-    assert response.json()["error"] == "Search term 'q' must be set"
-
-    # 'q' present but locale invalid
+    # locale invalid
     response = user_client.get(url, {"q": "x", "locale": "xxx"})
     assert response.status_code == 400
     assert response.json()["error"] == "Not a valid locale code"
 
-    # 'q' present but contains new line
+    # 'q' contains new line
     response = user_client.get(url, {"q": r"test\nsomething"})
     assert response.status_code == 400
     assert response.json()["q"] == ["Search term must not contain new line"]
 
-    # 'q' present but exceeds max allowed characters
+    # 'q' exceeds max allowed characters
     response = user_client.get(url, {"q": "x" * (settings.ES_Q_MAXLENGTH + 1)})
     assert response.status_code == 400
     assert response.json()["q"] == [
diff --git a/kuma/core/tests/test_views.py b/kuma/core/tests/test_views.py
--- a/kuma/core/tests/test_views.py
+++ b/kuma/core/tests/test_views.py
@@ -96,29 +96,11 @@ def test_global_home(self):
         doc = pq(r.content)
         assert m.message == doc.find("div.global-notice").text()
 
-    def test_subsection(self):
-        m = Message(message="Search", is_global=False, is_active=True, url="/search/")
-        m.save()
-
-        url = reverse("search")
-        r = self.client.get(url, follow=True)
-        assert 200 == r.status_code
-
-        doc = pq(r.content)
-        assert m.message == doc.find("div.global-notice").text()
-
-        url = reverse("home")
-        r = self.client.get(url, follow=True)
-        assert 200 == r.status_code
-
-        doc = pq(r.content)
-        assert not doc.find("div.global-notice")
-
     def test_inactive(self):
         m = Message(message="Search", is_global=False, is_active=False, url="/search/")
         m.save()
 
-        url = reverse("search")
+        url = reverse("home")
         r = self.client.get(url, follow=True)
         assert 200 == r.status_code
 
diff --git a/kuma/search/tests/test_views.py b/kuma/search/tests/test_views.py
--- a/kuma/search/tests/test_views.py
+++ b/kuma/search/tests/test_views.py
@@ -139,6 +139,13 @@ def test_allowed_methods(self):
         assert response.status_code == 405
         assert_no_cache_header(response)
 
+    def test_missing_q(self):
+        response = self.client.get("/en-US/search")
+        assert response.status_code == 400
+
+        response = self.client.get("/en-US/search?q=+")
+        assert response.status_code == 400
+
     def test_paginate_by_param(self):
         request = self.get_request("/en-US/search")