Repository: DDMAL/CantusDB

Issue:
global search bar - we should tweak policy for when it searches for Cantus IDs vs. for incipits
While looking into #1136, I tried to search for `isti sunt sancti*` in the search bar. Upon pressing enter, I got no results:

![Screenshot 2023-11-07 at 4 19 21 PM](https://github.com/DDMAL/CantusDB/assets/58090591/e219d932-08a9-4732-8c25-a872383c2588)

It seems that, since the search term contains a `*`, i.e., a non-letter character, CantusDB assumed I meant to search for a Cantus ID.

We should revisit the logic for deciding whether to display results by Cantus ID vs. by incipit - the deciding factor should probably be whether it contains a numeral, rather than whether it is only alphabetic.


Discussion:
Agreed! I think I ran across this for something similar (quotation marks maybe?). I think we can expect that all searches for cantusIDs will contain a numeral, and no searches for incipits will, so that seems like a good criterion (unless there is some "Ist1 sun7 sanc71" in the database somewhere...

Resolving patch:
diff --git a/django/cantusdb_project/main_app/views/chant.py b/django/cantusdb_project/main_app/views/chant.py
--- a/django/cantusdb_project/main_app/views/chant.py
+++ b/django/cantusdb_project/main_app/views/chant.py
@@ -540,7 +540,22 @@ def get_queryset(self) -> QuerySet:
             else:
                 chant_set = Chant.objects.filter(source__published=True)
                 sequence_set = Sequence.objects.filter(source__published=True)
-            if self.request.GET.get("search_bar").replace(" ", "").isalpha():
+
+            search_bar_term_contains_digits = any(
+                map(str.isdigit, self.request.GET.get("search_bar"))
+            )
+            if search_bar_term_contains_digits:
+                # if search bar is doing Cantus ID search
+                cantus_id = self.request.GET.get("search_bar")
+                q_obj_filter &= Q(cantus_id__icontains=cantus_id)
+                chant_set = chant_set.filter(q_obj_filter).values(
+                    *CHANT_SEARCH_TEMPLATE_VALUES
+                )
+                sequence_set = sequence_set.filter(q_obj_filter).values(
+                    *CHANT_SEARCH_TEMPLATE_VALUES
+                )
+                queryset = chant_set.union(sequence_set, all=True)
+            else:
                 # if search bar is doing incipit search
                 search_term = self.request.GET.get("search_bar")
                 ms_spelling_filter = Q(manuscript_full_text__istartswith=search_term)
@@ -558,17 +573,6 @@ def get_queryset(self) -> QuerySet:
                     *CHANT_SEARCH_TEMPLATE_VALUES
                 )
                 queryset = chant_set.union(sequence_set, all=True)
-            else:
-                # if search bar is doing Cantus ID search
-                cantus_id = self.request.GET.get("search_bar")
-                q_obj_filter &= Q(cantus_id__icontains=cantus_id)
-                chant_set = chant_set.filter(q_obj_filter).values(
-                    *CHANT_SEARCH_TEMPLATE_VALUES
-                )
-                sequence_set = sequence_set.filter(q_obj_filter).values(
-                    *CHANT_SEARCH_TEMPLATE_VALUES
-                )
-                queryset = chant_set.union(sequence_set, all=True)
             queryset = queryset.order_by("source__siglum", "id")
 
         else:
diff --git a/django/cantusdb_project/main_app/views/views.py b/django/cantusdb_project/main_app/views/views.py
--- a/django/cantusdb_project/main_app/views/views.py
+++ b/django/cantusdb_project/main_app/views/views.py
@@ -1,5 +1,6 @@
 import csv
 from typing import Optional, Union
+from django.db.models.query import QuerySet
 from django.http.response import JsonResponse
 from django.http import HttpResponse, HttpResponseNotFound
 from django.shortcuts import render, redirect
@@ -409,36 +410,39 @@ def ajax_search_bar(request, search_term):
         JsonResponse: A response to the AJAX call, to be unpacked by frontend js code
     """
     # load only the first seven chants
-    CHANT_CNT = 7
+    CHANT_CNT: int = 7
 
-    if not search_term.replace(" ", "").isalpha():
+    chants: QuerySet[Chant]
+    if any(map(str.isdigit, search_term)):
         # if the search term contains at least one digit, assume user is searching by Cantus ID
         chants = Chant.objects.filter(cantus_id__istartswith=search_term).order_by("id")
     else:
         # if the search term does not contain any digits, assume user is searching by incipit
         chants = Chant.objects.filter(incipit__istartswith=search_term).order_by("id")
 
-    display_unpublished = request.user.is_authenticated
+    display_unpublished: bool = request.user.is_authenticated
     if not display_unpublished:
         chants = chants.filter(source__published=True)
 
     chants = chants[:CHANT_CNT]
 
-    returned_values = chants.values(
-        "incipit",
-        "genre__name",
-        "feast__name",
-        "cantus_id",
-        "mode",
-        "siglum",
-        "office__name",
-        "folio",
-        "c_sequence",
+    returned_values: list[dict] = list(
+        chants.values(
+            "id",  # not used in the js, but used to calculate chant_link below
+            "incipit",
+            "genre__name",
+            "feast__name",
+            "cantus_id",
+            "mode",
+            "source__siglum",
+            "office__name",
+            "folio",
+            "c_sequence",
+        )
     )
-    returned_values = list(returned_values)
-    for i in range(chants.count()):
-        chant_link = chants[i].get_absolute_url()
-        returned_values[i]["chant_link"] = chant_link
+    for values_for_chant in returned_values:
+        chant_link = reverse("chant-detail", args=[values_for_chant["id"]])
+        values_for_chant["chant_link"] = chant_link
     return JsonResponse({"chants": returned_values}, safe=True)
 
 


Test patch:
diff --git a/django/cantusdb_project/main_app/tests/test_views.py b/django/cantusdb_project/main_app/tests/test_views.py
--- a/django/cantusdb_project/main_app/tests/test_views.py
+++ b/django/cantusdb_project/main_app/tests/test_views.py
@@ -994,6 +994,70 @@ def test_keyword_search_contains(self):
         context_chant_id = response.context["chants"][0]["id"]
         self.assertEqual(chant.id, context_chant_id)
 
+    def test_search_bar_search(self):
+        # note to developers: if you are changing the behavior of search_bar
+        # searches, be sure to check static/js/chant_search.js to see if it needs
+        # updating (among other things, this script populates the search fields on
+        # the Chant Search page if a user arrives via the Global Search Bar)
+        chant_with_incipit_only = make_fake_chant(
+            manuscript_full_text="The first three*", cantus_id="987654"
+        )
+        chant_with_full_text = make_fake_chant(
+            manuscript_full_text="The entire text is present for this one",
+            cantus_id="098765",
+        )
+        chant_with_ascending_cantus_id = make_fake_chant(
+            manuscript_full_text="Full text contains, but does not start with 'the'",
+            cantus_id="123456",
+        )
+        chant_starting_with_a_number = make_fake_chant(
+            manuscript_full_text=(
+                "1 is a number. " "How unusual, to find an arabic numeral in a chant!"
+            ),
+            cantus_id="234567",
+        )
+
+        # if the search term contains no numerals, we should be doing an incipit
+        # search. Non-letter, non-numeral characters like "*" should not cause
+        # a switch to searching by Cantus ID
+        full_incipit_search_term = "the first three*"
+        response_1 = self.client.get(
+            reverse("chant-search"), {"search_bar": full_incipit_search_term}
+        )
+        context_chants_1 = response_1.context["chants"]
+        self.assertEqual(len(context_chants_1), 1)
+        context_chant_1_id = context_chants_1[0]["id"]
+        self.assertEqual(context_chant_1_id, chant_with_incipit_only.id)
+
+        short_incipit_search_term = "the"
+        response_2 = self.client.get(
+            reverse("chant-search"), {"search_bar": short_incipit_search_term}
+        )
+        context_chants_2 = response_2.context["chants"]
+        self.assertEqual(len(context_chants_2), 2)
+        context_chants_2_ids = context_chants_2[0]["id"], context_chants_2[1]["id"]
+        self.assertIn(chant_with_incipit_only.id, context_chants_2_ids)
+        self.assertIn(chant_with_full_text.id, context_chants_2_ids)
+        self.assertNotIn(chant_with_ascending_cantus_id.id, context_chants_2_ids)
+
+        # if the search term contains even a single numeral, we should be doing
+        # a Cantus ID search
+        numeric_search_term = "1"
+        response_3 = self.client.get(
+            reverse("chant-search"), {"search_bar": numeric_search_term}
+        )
+        context_chants_3 = response_3.context["chants"]
+        self.assertEqual(len(context_chants_3), 1)
+        context_chant_3_id = context_chants_3[0]["id"]
+        self.assertEqual(context_chant_3_id, chant_with_ascending_cantus_id.id)
+
+        letters_and_numbers_search_term = "1 is"
+        response_4 = self.client.get(
+            reverse("chant-search"), {"search_bar": letters_and_numbers_search_term}
+        )
+        context_chants_4 = response_4.context["chants"]
+        self.assertEqual(len(context_chants_4), 0)
+
     def test_order_by_siglum(self):
         source_1 = make_fake_source(published=True, siglum="sigl-1")
         chant_1 = make_fake_chant(incipit="thing 1", source=source_1)
@@ -5396,3 +5460,107 @@ def test_non_authenticated_user(self):
         self.assertEqual(response.status_code, 200)
         data = response.json()
         self.assertEqual(len(data["results"]), 0)
+
+
+class AjaxSearchBarTest(TestCase):
+    def test_response(self):
+        chant = make_fake_chant()
+        cantus_id = chant.cantus_id
+
+        response = self.client.get(reverse("ajax-search-bar", args=[cantus_id]))
+        self.assertEqual(response.status_code, 200)
+
+        content = json.loads(response.content)
+        self.assertIsInstance(content, dict)
+
+        content_chants = content["chants"]
+        self.assertIsInstance(content_chants, list)
+
+        content_chant = content_chants[0]
+        expected_keys_and_values = {
+            "incipit": chant.incipit,
+            "genre__name": chant.genre.name,
+            "feast__name": chant.feast.name,
+            "cantus_id": chant.cantus_id,
+            "mode": chant.mode,
+            "source__siglum": chant.source.siglum,
+            "folio": chant.folio,
+            "c_sequence": chant.c_sequence,
+            "chant_link": reverse("chant-detail", args=[chant.id]),
+        }
+        for key, expected_value in expected_keys_and_values.items():
+            with self.subTest(key=key):
+                observed_value = content_chant[key]
+                self.assertEqual(expected_value, observed_value)
+
+    def test_incipit_search(self):
+        unremarkable_chant = make_fake_chant(
+            incipit=(
+                "The incipit contains no "
+                "numbers no asterisks and no punctuation "
+                "and is thus completely normal"
+            )
+        )
+        chant_with_asterisk = make_fake_chant(incipit="few words*")
+
+        istartswith_search_term = "the incipit"
+        istartswith_response = self.client.get(
+            reverse("ajax-search-bar", args=[istartswith_search_term])
+        )
+        istartswith_content = json.loads(istartswith_response.content)
+        istartswith_chants = istartswith_content["chants"]
+        self.assertEqual(len(istartswith_chants), 1)
+        istartswith_chant = istartswith_chants[0]
+        self.assertEqual(istartswith_chant["id"], unremarkable_chant.id)
+
+        # we should only find chants that begin with the search term
+        icontains_search_term = "contains no"
+        icontains_response = self.client.get(
+            reverse("ajax-search-bar", args=[icontains_search_term])
+        )
+        icontains_content = json.loads(icontains_response.content)
+        icontains_chants = icontains_content["chants"]
+        self.assertEqual(len(icontains_chants), 0)
+
+        # the search bar should only switch to a Cantus ID search when
+        # there are numerals present. Special characters like asterisks
+        # may occur in chant texts, and should still be treated as
+        # incipit searches
+        asterisk_search_term = "few words*"
+        asterisk_response = self.client.get(
+            reverse("ajax-search-bar", args=[asterisk_search_term])
+        )
+        asterisk_content = json.loads(asterisk_response.content)
+        asterisk_chants = asterisk_content["chants"]
+        self.assertEqual(len(asterisk_chants), 1)
+        asterisk_chant = asterisk_chants[0]
+        self.assertEqual(asterisk_chant["id"], chant_with_asterisk.id)
+
+    def test_cantus_id_search(self):
+        chant_with_normal_cantus_id = make_fake_chant(cantus_id="012345")
+        chant_with_numerals_in_incipit = make_fake_chant(
+            incipit="0 me! 0 my! This is unexpected!"
+        )
+
+        # for search terms that contain numerals, we should only return
+        # matches with the cantus_id field, and not the incipit field
+        matching_search_term = "0"
+        matching_response = self.client.get(
+            reverse("ajax-search-bar", args=[matching_search_term])
+        )
+        matching_content = json.loads(matching_response.content)
+        matching_chants = matching_content["chants"]
+        self.assertEqual(len(matching_chants), 1)
+        matching_chant = matching_chants[0]
+        matching_id = matching_chant["id"]
+        self.assertEqual(matching_id, chant_with_normal_cantus_id.id)
+        self.assertNotEqual(matching_id, chant_with_numerals_in_incipit.id)
+
+        # we should only return istartswith results, and not icontains results
+        non_matching_search_term = "1"
+        non_matching_response = self.client.get(
+            reverse("ajax-search-bar", args=[non_matching_search_term])
+        )
+        non_matching_content = json.loads(non_matching_response.content)
+        non_matching_chants = non_matching_content["chants"]
+        self.assertEqual(len(non_matching_chants), 0)