Repository: DDMAL/CantusDB

Issue:
Chant Create suggested chant autofill buttons could be refactored to use a single function
Currently, on the Chant Create page, for the buttons in the sidebar providing suggested texts/genres for chants, we create a JavaScript function named after each suggestion's Cantus ID. Since Cantus IDs can contain `.`s and `:`s, and JS function names cannot, we have to convert Cantus IDs into valid alphanumeric strings for this to work.

A better possibility could be to create a single function into which we can pass a Cantus ID and it would handle all the autofilling. Discussion of how to implement this can be found in #1019.

Thanks, @ahankinson, for this suggestion!


Discussion:

Resolving patch:
diff --git a/django/cantusdb_project/cantusindex.py b/django/cantusdb_project/cantusindex.py
new file mode 100644
--- /dev/null
+++ b/django/cantusdb_project/cantusindex.py
@@ -0,0 +1,153 @@
+"""
+A collection of functions for fetching data from
+Cantus Index's (CI's) various APIs.
+"""
+
+import requests
+from typing import Optional, Union, Callable
+from main_app.models import Genre
+
+CANTUS_INDEX_DOMAIN: str = "https://cantusindex.uwaterloo.ca"
+DEFAULT_TIMEOUT: float = 2  # seconds
+NUMBER_OF_SUGGESTED_CHANTS: int = 3  # this number can't be too large,
+# since for each suggested chant, we make a request to Cantus Index.
+# We haven't yet parallelized this process, so setting this number
+# too high will cause the Chant Create page to take a very long time
+# to load. If/when we parallelize this process, we want to limit
+# the size of the burst of requests sent to CantusIndex.
+
+
+def get_suggested_chants(
+    cantus_id: str, number_of_suggestions: int = NUMBER_OF_SUGGESTED_CHANTS
+) -> Optional[list[dict]]:
+    endpoint_path: str = f"/json-nextchants/{cantus_id}"
+    all_suggestions: Union[list, dict, None] = get_json_from_ci_api(endpoint_path)
+
+    if not isinstance(all_suggestions, list):
+        # get_json_from_ci_api timed out
+        # or CI returned a response with no suggestions.
+        return None
+
+    # when Cantus ID doesn't exist within CI, CI's api returns a 200 response with `['Cantus ID is not valid']`
+    first_suggestion = all_suggestions[0]
+    if not isinstance(first_suggestion, dict):
+        return None
+
+    sort_by_occurrences: Callable[[dict], int] = lambda suggestion: int(
+        suggestion["count"]
+    )
+    sorted_suggestions: list = sorted(
+        all_suggestions, key=sort_by_occurrences, reverse=True
+    )
+    trimmed_suggestions: list = sorted_suggestions[:number_of_suggestions]
+
+    suggested_chants: list[Optional[dict]] = []
+    for suggestion in trimmed_suggestions:
+        cantus_id: str = suggestion["cid"]
+        occurrences: int = int(suggestion["count"])
+        suggested_chants.append(get_suggested_chant(cantus_id, occurrences))
+
+    # filter out Cantus IDs where get_suggested_chant timed out
+    filtered_suggestions: list[dict] = [
+        sugg for sugg in suggested_chants if sugg is not None
+    ]
+
+    return filtered_suggestions
+
+
+def get_suggested_chant(
+    cantus_id: str, occurrences: int, timeout: float = DEFAULT_TIMEOUT
+) -> Optional[dict]:
+    """Given a Cantus ID and a number of occurrences, query one of Cantus Index's
+    APIs for information on that Cantus ID and return a dictionary
+    containing a full text, an incipit, the ID of that Cantus ID's genre, and
+    the number of occurrences for that Cantus ID
+
+    (Number of occurrences: this function is used on the Chant Create page,
+    to suggest Cantus IDs of chants that might follow a chant with the Cantus ID
+    of the most recently created chant within the current source. Number of occurrences
+    is provided by Cantus Index's /nextchants API, based on which chants follow which
+    other chants in existing manuscripts)
+
+    Args:
+        cantus_id (str): a Cantus ID
+        occurrences (int): the number of times chants with this Cantus ID follow chants
+            with the Cantus ID of the most recently created chant.
+
+    Returns:
+        Optional[dict]: A dictionary with the following keys:
+            - "cantus_id"
+            - "occurrences"
+            - "fulltext"
+            - "incipit"
+            - "genre_id"
+            ...but if get_json_from_ci_api timed out, returns None instead
+    """
+    endpoint_path: str = f"/json-cid/{cantus_id}"
+    json: Union[dict, list, None] = get_json_from_ci_api(endpoint_path, timeout=timeout)
+
+    if not isinstance(json, dict):
+        # mostly, in case of a timeout within get_json_from_ci_api
+        return None
+
+    fulltext: str = json["info"]["field_full_text"]
+    incipit: str = " ".join(fulltext.split(" ")[:5])
+    genre_name: str = json["info"]["field_genre"]
+    genre_id: Optional[int] = None
+    try:
+        genre_id = Genre.objects.get(name=genre_name).id
+    except Genre.DoesNotExist:
+        pass
+
+    clean_cantus_id = cantus_id.replace(".", "d").replace(":", "c")
+    #                                        "d"ot             "c"olon
+    return {
+        "cantus_id": cantus_id,
+        "occurrences": occurrences,
+        "fulltext": fulltext,
+        "incipit": incipit,
+        "genre_name": genre_name,
+        "genre_id": genre_id,
+        "clean_cantus_id": clean_cantus_id,
+    }
+
+
+def get_json_from_ci_api(
+    path: str, timeout: float = DEFAULT_TIMEOUT
+) -> Union[dict, list, None]:
+    """Given a path, send a request to Cantus Index at that path,
+    decode the response to remove its Byte Order Marker, parse it,
+    and return it as a dictionary or list.
+
+    Args:
+        path (str): The path of the Cantus Index endpoint, including a leading "/"
+        timeout (int): how long to wait for a response before giving
+            up and returning None.
+
+    Returns:
+        Union[dict, list, None]:
+            If the JSON returned from Cantus Index is a JSON object, returns a dict.
+            If the JSON returned is a JSON array, returns a list.
+            In case the request times out, returns None.
+    """
+
+    if not path.startswith("/"):
+        raise ValueError('path must begin with "/"')
+
+    uri = f"{CANTUS_INDEX_DOMAIN}{path}"
+    try:
+        response: requests.Response = requests.get(uri, timeout=timeout)
+    except requests.exceptions.Timeout:
+        return None
+
+    if not response.status_code == 200:
+        return None  # /json-cid/Non-existentCantusId returns a 500 page
+
+    response.encoding = "utf-8-sig"
+
+    if not response.text.strip():
+        # /json-nextchants returns a response with text='\r\n' in situations where
+        # there are no suggested chants
+        return None
+
+    return response.json()
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
@@ -44,6 +44,7 @@
     flatten_syllabified_text,
 )
 from volpiano_display_utilities.text_volpiano_alignment import align_text_and_volpiano
+from cantusindex import get_suggested_chants
 
 CHANT_SEARCH_TEMPLATE_VALUES: tuple[str, ...] = (
     # for views that use chant_search.html, this allows them to
@@ -855,11 +856,23 @@ def get_suggested_feasts(self):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context["source"] = self.source
+        previous_chant: Optional[Chant] = None
         try:
-            context["previous_chant"] = self.source.chant_set.latest("date_updated")
+            previous_chant = self.source.chant_set.latest("date_updated")
         except Chant.DoesNotExist:
-            context["previous_chant"] = None
+            pass
+        context["previous_chant"] = previous_chant
         context["suggested_feasts"] = self.get_suggested_feasts()
+
+        previous_cantus_id: Optional[str] = None
+        if previous_chant:
+            previous_cantus_id = previous_chant.cantus_id
+
+        suggested_chants: Optional[list[dict]] = None
+        if previous_cantus_id:
+            suggested_chants = get_suggested_chants(previous_cantus_id)
+        context["suggested_chants"] = suggested_chants
+
         return context
 
     def form_valid(self, form):


Test patch:
diff --git a/django/cantusdb_project/main_app/tests/mock_cantusindex_data.py b/django/cantusdb_project/main_app/tests/mock_cantusindex_data.py
new file mode 100644
--- /dev/null
+++ b/django/cantusdb_project/main_app/tests/mock_cantusindex_data.py
@@ -0,0 +1,385 @@
+import ujson
+
+#######################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-nextchants/001010") ###
+#######################################################################################
+
+mock_json_nextchants_001010_text: str = """
+[
+    {"cid":"008349", "count": "12"},
+    {"cid":"006928", "count": "17"},
+    {"cid":"008411c","count":"4"},
+    {"cid":"008390","count":"3"},
+    {"cid":"007713","count":"2"},
+    {"cid":"909030","count":"1"}
+]
+"""
+# should be equivalent to:
+# >>> requests.get("https://cantusindex.uwaterloo.ca/json-nextchants/001010").text
+# this doesn't include the BOM which we expect to see beginning response.text
+# CI seems to present these, sorted by "count", in descending order.
+# Here, they have been switched around so we can ensure that our own
+# sorting by number of occurrences is working properly
+
+mock_json_nextchants_001010_content: bytes = bytes(
+    mock_json_nextchants_001010_text,
+    encoding="utf-8-sig",
+)  # requests.get("https://cantusindex.uwaterloo.ca/json-nextchants/001010").content
+# we expect request.content to be encoded in signed utf-8. I.e., this step adds the BOM
+mock_json_nextchants_001010_json: list[dict] = ujson.loads(
+    mock_json_nextchants_001010_text
+)
+# should be equivalent to:
+# >>> request = requests.get("https://cantusindex.uwaterloo.ca/json-nextchants/001010")
+# >>> request.encoding = "utf-8-sig"
+# >>> request.json()
+
+
+#######################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-nextchants/a07763") ###
+#######################################################################################
+
+mock_json_nextchants_a07763_text: str = "\r\n"
+mock_json_nextchants_a07763_content: bytes = bytes(
+    mock_json_nextchants_a07763_text,
+    encoding="utf-8-sig",
+)
+
+################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-cid/008349") ###
+################################################################################
+
+mock_json_cid_008349_text: str = """
+{
+    "info": {
+        "field_ah_item": null,
+        "field_ah_volume": null,
+        "field_cao": "8349",
+        "field_cao_concordances": "S",
+        "field_feast": "Dominica in estate",
+        "field_full_text": "Nocte surgentes vigilemus omnes semper in psalmis meditemur atque viribus totis domino canamus dulciter hymnos | Ut pio regi pariter canentes cum suis sanctis mereamur aulam ingredi caeli simul et beatam ducere vitam | Praestet hoc nobis deitas beata patris ac nati pariterque sancti spiritus cujus resonat per omnem gloria mundum | Amen",
+        "field_fulltext_source": null,
+        "field_genre": "H",
+        "field_language": null,
+        "field_notes": null,
+        "field_related_chant_id": null,
+        "field_similar_chant_id": null,
+        "field_troped_chant_id": null
+    },
+    "chants": {
+        "0": {
+            "siglum": "P-BRs Ms. 032",
+            "srclink": "https://pemdatabase.eu/source/47990",
+            "chantlink": "https://pemdatabase.eu/musical-item/54477",
+            "folio": "262r",
+            "sequence": null,
+            "incipit": "Nocte surgentes*",
+            "feast": "Dom. 2 p. Pent.",
+            "office": "M",
+            "genre": "H",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/71166",
+            "mode": "*",
+            "fulltext": "Nocte surgentes*",
+            "melody": "",
+            "db": "PEM"
+        },
+        "1": {
+            "siglum": "P-Cua Liber Catenatus ",
+            "srclink": "https://pemdatabase.eu/source/46520",
+            "chantlink": "https://pemdatabase.eu/musical-item/94677",
+            "folio": "001v",
+            "sequence": null,
+            "incipit": "Nocte surgentes vigilemus omnes semper in",
+            "feast": "Dom. per annum",
+            "office": "M",
+            "genre": "H",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/58092",
+            "mode": "*",
+            "fulltext": "Nocte surgentes vigilemus omnes semper in psalmis meditemur atque viribus totis domino canamus dulciter hymnos | Ut pio regi pariter canentes cum suis sanctis mereamur aulam ingredi caeli simul et beatam ducere vitam | Praestet hoc nobis deitas beata patris ac nati pariterque sancti spiritus cujus reboat per omnem gloria mundum | Amen",
+            "melody": "",
+            "db": "PEM"
+        }
+    }
+}
+"""
+mock_json_cid_008349_content: bytes = bytes(
+    mock_json_cid_008349_text,
+    encoding="utf-8-sig",
+)
+mock_json_cid_008349_json: dict = ujson.loads(mock_json_cid_008349_text)
+
+################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-cid/006928") ###
+################################################################################
+
+mock_json_cid_006928_text: str = """
+{
+    "info": {
+        "field_ah_item": null,
+        "field_ah_volume": null,
+        "field_cao": "6928",
+        "field_cao_concordances": "CGBEMVHRDFSL",
+        "field_feast": "Dom. Septuagesimae",
+        "field_full_text": "In principio fecit deus caelum et terram et creavit in ea hominem ad imaginem et similitudinem suam",
+        "field_fulltext_source": null,
+        "field_genre": "R",
+        "field_language": null,
+        "field_notes": null,
+        "field_related_chant_id": null,
+        "field_similar_chant_id": null,
+        "field_troped_chant_id": null
+    },
+    "chants": {
+        "0": {
+            "siglum": "P-BRs Ms. 032",
+            "srclink": "https://pemdatabase.eu/source/47990",
+            "chantlink": "https://pemdatabase.eu/musical-item/51656",
+            "folio": "119r",
+            "sequence": null,
+            "incipit": "In principio creauit deus celum et ",
+            "feast": "Dom. Septuagesimae",
+            "office": "M",
+            "genre": "R",
+            "position": "1",
+            "image": "https://pemdatabase.eu/image/71015",
+            "mode": "1",
+            "fulltext": "In principio creauit deus celum et terram et fecit in ea hominem ad imaginem et similitudinem suam",
+            "melody": "",
+            "db": "PEM"
+        },
+        "1": {
+            "siglum": "P-AR Res. Ms. 021",
+            "srclink": "https://pemdatabase.eu/source/62867",
+            "chantlink": "https://pemdatabase.eu/musical-item/65240",
+            "folio": "063r",
+            "sequence": null,
+            "incipit": "In principio*",
+            "feast": "Dom. Septuagesimae",
+            "office": "V",
+            "genre": "R",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/63266",
+            "mode": "1",
+            "fulltext": "In principio*",
+            "melody": "",
+            "db": "PEM"
+        }
+    }
+}
+"""
+mock_json_cid_006928_content: bytes = bytes(
+    mock_json_cid_006928_text,
+    encoding="utf-8-sig",
+)
+mock_json_cid_006928_json: dict = ujson.loads(mock_json_cid_006928_text)
+
+#################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-cid/008411c") ###
+#################################################################################
+
+mock_json_cid_008411c_text: str = """
+{
+    "info": {
+        "field_ah_item": null,
+        "field_ah_volume": null,
+        "field_cao": null,
+        "field_cao_concordances": "SL",
+        "field_feast": "Mariae Magdalenae",
+        "field_full_text": "Hujus obtentu deus alme nostris parce jam culpis vitiis revulsis quo tibi puri resonet per aevum pectoris hymnus",
+        "field_fulltext_source": null,
+        "field_genre": "HV",
+        "field_language": null,
+        "field_notes": null,
+        "field_related_chant_id": null,
+        "field_similar_chant_id": null,
+        "field_troped_chant_id": null
+    },
+    "chants": {
+        "1": {
+            "siglum": "P-ARRam ACA/E/002/Lv 002",
+            "srclink": "https://pemdatabase.eu/source/103757",
+            "chantlink": "https://pemdatabase.eu/musical-item/113818",
+            "folio": "r",
+            "sequence": null,
+            "incipit": "Hujus abtendu deus*",
+            "feast": "Mariae Magdalenae",
+            "office": "M",
+            "genre": "H",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/103759",
+            "mode": "*",
+            "fulltext": "Hujus abtendu deus*",
+            "melody": "",
+            "db": "PEM"
+        },
+        "0": {
+            "siglum": "P-Ln RES. 253 P.",
+            "srclink": "https://pemdatabase.eu/source/97935",
+            "chantlink": "https://pemdatabase.eu/musical-item/107270",
+            "folio": "189r",
+            "sequence": null,
+            "incipit": "Harum obtentu deus alme nostris parce",
+            "feast": "Comm. plurimorum Virginum",
+            "office": "V",
+            "genre": "HV",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/102010",
+            "mode": "*",
+            "fulltext": "Harum obtentu deus alme nostris parce jam culpis vitia revulsis quo tibi puri resonemus almum pectoris hymnus | Gloria patri genitoque proli et tibi compar utriusque semper super alme deus unus omni tempore saeculi",
+            "melody": "",
+            "db": "PEM"
+        }
+    }
+}
+"""
+mock_json_cid_008411c_content: bytes = bytes(
+    mock_json_cid_008411c_text,
+    encoding="utf-8-sig",
+)
+mock_json_cid_008411c_json: dict = ujson.loads(mock_json_cid_008411c_text)
+
+################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-cid/008390") ###
+################################################################################
+
+mock_json_cid_008390_text: str = """
+{
+    "info": {
+        "field_ah_item": null,
+        "field_ah_volume": null,
+        "field_cao": "8390",
+        "field_cao_concordances": "SL",
+        "field_feast": "Comm. plurimorum Martyrum",
+        "field_full_text": "Sanctorum meritis inclyta gaudia pangamus socii gestaque fortia gliscit animus promere cantibus victorum genus optimum",
+        "field_fulltext_source": "A-HE (Heiligenkreuz im Wienerwald) 20, fol. 240v-241r",
+        "field_genre": "H",
+        "field_language": null,
+        "field_notes": "Full text submitted by Veronika Mr\u00e1\u010dkov\u00e1 | cf. 008390.1 for the 1632 Urban VIII revision of this hymn, which begins the same but contains multiple differences",
+        "field_related_chant_id": null,
+        "field_similar_chant_id": null,
+        "field_troped_chant_id": null
+    },
+    "chants": {
+        "0": {
+            "siglum": "P-BRs Ms. 028",
+            "srclink": "https://pemdatabase.eu/source/48438",
+            "chantlink": "https://pemdatabase.eu/musical-item/44336",
+            "folio": "017r",
+            "sequence": null,
+            "incipit": "Sanctorum meritis*",
+            "feast": "Nat. Innocentium",
+            "office": "V2",
+            "genre": "H",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/85920",
+            "mode": "*",
+            "fulltext": "Sanctorum meritis*",
+            "melody": "",
+            "db": "PEM"
+        }
+	}
+}
+"""
+mock_json_cid_008390_content: bytes = bytes(
+    mock_json_cid_008390_text,
+    encoding="utf-8-sig",
+)
+mock_json_cid_008390_json: dict = ujson.loads(mock_json_cid_008390_text)
+
+################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-cid/007713") ###
+################################################################################
+
+mock_json_cid_007713_text: str = """
+{
+    "info": {
+        "field_ah_item": null,
+        "field_ah_volume": null,
+        "field_cao": "7713",
+        "field_cao_concordances": "CGBEMVHRDFSL",
+        "field_feast": "Nat. Innocentium",
+        "field_full_text": "Sub altare dei audivi voces occisorum dicentium quare non defendis sanguinem nostrum et acceperunt divinum responsum adhuc sustinete modicum tempus donec impleatur numerus fratrum vestrorum",
+        "field_fulltext_source": null,
+        "field_genre": "R",
+        "field_language": null,
+        "field_notes": null,
+        "field_related_chant_id": null,
+        "field_similar_chant_id": null,
+        "field_troped_chant_id": null
+    },
+    "chants": {
+        "0": {
+            "siglum": "P-BRs Ms. 028",
+            "srclink": "https://pemdatabase.eu/source/48438",
+            "chantlink": "https://pemdatabase.eu/musical-item/44288",
+            "folio": "013r",
+            "sequence": null,
+            "incipit": "Sub altare dei audivi voces occisorum ",
+            "feast": "Nat. Innocentium",
+            "office": "M",
+            "genre": "R",
+            "position": "1.1",
+            "image": "https://pemdatabase.eu/image/85916",
+            "mode": "7",
+            "fulltext": "Sub altare dei audivi voces occisorum dicentium quare non defendis sanguinem nostrum et acceperunt divinum responsum adhuc sustinete modicum tempus donec impleatur numerus fratrum vestrorum",
+            "melody": "",
+            "db": "PEM"
+        }
+    }
+}
+"""
+mock_json_cid_007713_content: bytes = bytes(
+    mock_json_cid_007713_text,
+    encoding="utf-8-sig",
+)
+mock_json_cid_007713_json: dict = ujson.loads(mock_json_cid_007713_text)
+
+################################################################################
+### mocking requests.get("https://cantusindex.uwaterloo.ca/json-cid/909030") ###
+################################################################################
+
+mock_json_cid_909030_text: str = """
+{
+    "info": {
+        "field_ah_item": null,
+        "field_ah_volume": null,
+        "field_cao": null,
+        "field_cao_concordances": null,
+        "field_feast": "Invitatoria",
+        "field_full_text": "Venite exsultemus domino jubilemus deo salutari nostro praeoccupemus faciem ejus in confessione et in psalmis jubilemus ei | Quoniam deus magnus dominus et rex magnus super omnes deos quoniam non repellet dominus plebem suam quia in manu ejus sunt omnes fines terrae et altitudines montium ipse conspicit | Quoniam ipsius est mare et ipse fecit illud et aridam fundaverunt manus ejus venite adoremus et procidamus ante deum ploremus coram domino qui fecit nos quia ipse est dominus deus noster nos autem populus ejus et oves pascuae ejus | Hodie si vocem ejus audieritis nolite obdurare corda vestra sicut in exacerbatione secundum diem tentationis in deserto ubi tentaverunt me patres vestri probaverunt et viderunt opera mea | Quadraginta annis proximus fui generationi huic et dixi semper hi errant corde ipsi vero non cognoverunt vias meas quibus juravi in ira mea si introibunt in requiem meam | Gloria patri et filio et spiritui sancto sicut erat in principio et nunc et semper et in saecula saeculorum amen",
+        "field_fulltext_source": null,
+        "field_genre": "IP",
+        "field_language": null,
+        "field_notes": null,
+        "field_related_chant_id": null,
+        "field_similar_chant_id": null,
+        "field_troped_chant_id": null
+    },
+    "chants": {
+        "0": {
+            "siglum": "P-Cug MM 0045",
+            "srclink": "https://pemdatabase.eu/source/46513",
+            "chantlink": "https://pemdatabase.eu/musical-item/50730",
+            "folio": "029r",
+            "sequence": null,
+            "incipit": "Venite exsultemus domino jubilemus deo salutari",
+            "feast": "De festis duplicibus",
+            "office": "M",
+            "genre": "IP",
+            "position": "",
+            "image": "https://pemdatabase.eu/image/84007",
+            "mode": "",
+            "fulltext": "Venite exsultemus domino jubilemus deo salutari nostro praeoccupemus faciem ejus in confessione et in psalmis jubilemus ei | Quoniam deus magnus | Quoniam ipsius est mare et ipse fecit illud et aridam fundaverunt manus ejus venite adoremus et procidamus ante deum ploremus coram domino qui fecit nos quia ipse est dominus deus noster nos autem populus ejus et oves pascuae ejus | Hodie si vocem | Quadraginta annis proximus fui generationi huic et dixi semper hi errant corde ipsi vero non cognoverunt vias meas quibus juravi in ira mea si introibunt in requiem meam | Gloria",
+            "melody": "",
+            "db": "PEM"
+		}
+    }
+}
+"""
+mock_json_cid_909030_content: bytes = bytes(
+    mock_json_cid_909030_text,
+    encoding="utf-8-sig",
+)
+mock_json_cid_909030_json: dict = ujson.loads(mock_json_cid_909030_text)
diff --git a/django/cantusdb_project/main_app/tests/test_functions.py b/django/cantusdb_project/main_app/tests/test_functions.py
--- a/django/cantusdb_project/main_app/tests/test_functions.py
+++ b/django/cantusdb_project/main_app/tests/test_functions.py
@@ -1,20 +1,159 @@
+import requests
+from main_app.tests import mock_cantusindex_data
 from django.test import TestCase
-from typing import Union
+from typing import Union, Optional
+from unittest.mock import patch
 from main_app.models import (
     Chant,
     Source,
 )
 from main_app.tests.make_fakes import (
     make_fake_chant,
+    make_fake_genre,
     make_fake_source,
 )
 from main_app.management.commands import update_cached_concordances
 from main_app.signals import generate_incipit
+from cantusindex import (
+    get_suggested_chant,
+    get_suggested_chants,
+    get_json_from_ci_api,
+    CANTUS_INDEX_DOMAIN,
+)
 
 # run with `python -Wa manage.py test main_app.tests.test_functions`
 # the -Wa flag tells Python to display deprecation warnings
 
 
+class MockResponse:
+    def __init__(
+        self,
+        status_code: int,
+        text: Optional[str],
+        json: Union[dict, list, None],
+        content: Optional[bytes],
+        encoding: str = "utf-8",
+        # >>> response = requests.get("https://cantusindex.uwaterloo.ca/json-nextchants/001010")
+        # >>> response.encoding
+        # 'utf-8'
+    ):
+        self.status_code = status_code
+        self._json = json
+        self.content = content
+        self.encoding = encoding
+        self.text = text
+
+    def json(self):
+        return self._json
+
+
+def mock_requests_get(url: str, timeout: float) -> MockResponse:
+    """Return a mock response. Used to patch calls to requests.get in tests below
+
+    Args:
+        url (str): a URL - a necessary argument for requests.get
+        timeout (int): we pass timeout as an argument to requests.get in get_json_from_ci_api,
+            so mock_requests_get is configured to accept this argument.
+
+    Raises:
+        ValueError: This function is configured to mock requests to specific URLs only, including
+            - /json-nextchants/001010
+        If a call to requests.get with a different URL is made while mock_requests_get is patching it,
+        a NotImplementedError is raised.
+
+    Returns:
+        MockResponse: A mock response object
+    """
+    if timeout < 0.001:
+        raise requests.exceptions.ConnectTimeout
+
+    if not (CANTUS_INDEX_DOMAIN in url):
+        raise NotImplementedError(
+            f"mock_requests_get is only set up to mock calls to Cantus Index. "
+            f"The protocol and domain of url {url} do not correspond to those of Cantus Index."
+        )
+
+    if f"{CANTUS_INDEX_DOMAIN}/json-nextchants/" in url:
+        if url.endswith("/001010"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_nextchants_001010_content,
+                text=mock_cantusindex_data.mock_json_nextchants_001010_text,
+                json=mock_cantusindex_data.mock_json_nextchants_001010_json,
+            )
+        elif url.endswith("/a07763"):
+            # this Cantus ID has no suggested chants
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_nextchants_a07763_content,
+                text=mock_cantusindex_data.mock_json_nextchants_a07763_text,
+                json=None,
+            )
+        else:  # imitating CI's behavior when a made-up Cantus ID is entered.
+            return MockResponse(
+                status_code=200,
+                content=bytes('["Cantus ID is not valid"]', encoding="utf-8-sig"),
+                text='["Cantus ID is not valid"]',
+                json=["Cantus ID is not valid"],
+            )
+    elif f"{CANTUS_INDEX_DOMAIN}/json-cid/" in url:
+        if url.endswith("/008349"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_cid_008349_content,
+                text=mock_cantusindex_data.mock_json_cid_008349_text,
+                json=mock_cantusindex_data.mock_json_cid_008349_json,
+            )
+        elif url.endswith("/006928"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_cid_006928_content,
+                text=mock_cantusindex_data.mock_json_cid_006928_text,
+                json=mock_cantusindex_data.mock_json_cid_006928_json,
+            )
+        elif url.endswith("/008411c"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_cid_008411c_content,
+                text=mock_cantusindex_data.mock_json_cid_008411c_text,
+                json=mock_cantusindex_data.mock_json_cid_008411c_json,
+            )
+        elif url.endswith("/008390"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_cid_008390_content,
+                text=mock_cantusindex_data.mock_json_cid_008390_text,
+                json=mock_cantusindex_data.mock_json_cid_008390_json,
+            )
+        elif url.endswith("/007713"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_cid_007713_content,
+                text=mock_cantusindex_data.mock_json_cid_007713_text,
+                json=mock_cantusindex_data.mock_json_cid_007713_json,
+            )
+        elif url.endswith("/909030"):
+            return MockResponse(
+                status_code=200,
+                content=mock_cantusindex_data.mock_json_cid_909030_content,
+                text=mock_cantusindex_data.mock_json_cid_909030_text,
+                json=mock_cantusindex_data.mock_json_cid_909030_json,
+            )
+        else:  # imitating CI's behavior when a made-up Cantus ID is entered.
+            return MockResponse(
+                status_code=500,
+                content=None,
+                text=None,
+                json=None,
+            )
+    else:
+        raise NotImplementedError(
+            f"mock_requests_get is only set up to imitate only the /json-nextchants/ "
+            f"and /json-cid/ endpoints on Cantus Index. The path of the url {url} does "
+            f"not match either of these endpoints."
+        )
+
+
 class UpdateCachedConcordancesCommandTest(TestCase):
     def test_concordances_structure(self):
         chant: Chant = make_fake_chant(cantus_id="123456")
@@ -118,3 +257,149 @@ def test_generate_incipit(self):
         observed_incipit_2 = generate_incipit(short_fulltext)
         with self.subTest(test="fulltext that's already a short incipit"):
             self.assertEqual(observed_incipit_2, expected_incipit_2)
+
+
+class CantusIndexFunctionsTest(TestCase):
+    def test_get_suggested_chant(self):
+        h_genre = make_fake_genre(name="H")
+        occs = 3
+        expected_chant = {
+            "cantus_id": "008349",
+            "occurrences": occs,
+            "fulltext": "Nocte surgentes vigilemus omnes semper in psalmis meditemur atque viribus totis domino canamus dulciter hymnos | Ut pio regi pariter canentes cum suis sanctis mereamur aulam ingredi caeli simul et beatam ducere vitam | Praestet hoc nobis deitas beata patris ac nati pariterque sancti spiritus cujus resonat per omnem gloria mundum | Amen",
+            "incipit": "Nocte surgentes vigilemus omnes semper",
+            "genre_name": "H",
+            "genre_id": h_genre.id,
+            "clean_cantus_id": "008349",
+        }
+        with patch("requests.get", mock_requests_get):
+            observed_chant = get_suggested_chant(cantus_id="008349", occurrences=occs)
+
+        expected_keys = expected_chant.keys()
+        observed_keys = observed_chant.keys()
+        with self.subTest(test="Ensure suggested chant includes the right keys"):
+            self.assertEqual(observed_keys, expected_keys)
+
+        for key in expected_keys:
+            expected_val = expected_chant[key]
+            observed_val = observed_chant[key]
+            with self.subTest(key=key):
+                self.assertEqual(observed_val, expected_val)
+
+        with self.subTest("Ensure all values are of the correct type"):
+            self.assertIsInstance(observed_chant["cantus_id"], str)
+            self.assertIsInstance(observed_chant["occurrences"], int)
+
+        with patch("requests.get", mock_requests_get):
+            observed_chant_with_r_genre = get_suggested_chant(
+                cantus_id="006928", occurrences=occs
+            )
+        with self.subTest(
+            test="Ensure that genre_id=None when no matching Genre found"
+        ):
+            observed_genre_id = observed_chant_with_r_genre["genre_id"]
+            self.assertIsNone(observed_genre_id)
+
+        with patch("requests.get", mock_requests_get):
+            observed_chant_short_timeout = get_suggested_chant(
+                cantus_id="008349", occurrences=occs, timeout=0.0001
+            )
+        with self.subTest(test="Ensure None is returned in case of timeout"):
+            self.assertIsNone(observed_chant_short_timeout)
+
+        with patch("requests.get", mock_requests_get):
+            observed_chant_nonexistent_cantus_id = get_suggested_chant(
+                cantus_id="NotACantusID",
+                occurrences=occs,
+            )
+        with self.subTest(
+            test="Ensure None is returned in case of nonexistent Cantus ID"
+        ):
+            self.assertIsNone(observed_chant_nonexistent_cantus_id)
+
+    def test_get_suggested_chants(self):
+        expected_number_of_suggestions: int = 3
+        with patch("requests.get", mock_requests_get):
+            suggested_chants = get_suggested_chants(cantus_id="001010")
+
+        initial_suggested_chant = suggested_chants[0]
+
+        with self.subTest(test="Ensure returned object is a list of dicts"):
+            self.assertIsInstance(suggested_chants, list)
+            self.assertIsInstance(initial_suggested_chant, dict)
+
+        with self.subTest(
+            test=f"Ensure no more than {expected_number_of_suggestions} suggestions returned"
+        ):
+            self.assertLessEqual(len(suggested_chants), expected_number_of_suggestions)
+
+        with self.subTest(
+            test="Ensure suggested chants are ordered by number of occurrences"
+        ):
+            for i in range(expected_number_of_suggestions - 1):
+                suggested_chant = suggested_chants[i]
+                following_suggested_chant = suggested_chants[i + 1]
+                self.assertGreaterEqual(
+                    suggested_chant["occurrences"],
+                    following_suggested_chant["occurrences"],
+                )
+
+        with patch("requests.get", mock_requests_get):
+            suggested_chants_nonexistent_cantus_id = get_suggested_chants(
+                "NotACantusID"
+            )
+        with self.subTest(test="Ensure None returned in case of nonexistent Cantus ID"):
+            self.assertIsNone(suggested_chants_nonexistent_cantus_id)
+
+        with patch("requests.get", mock_requests_get):
+            suggested_chants_rare_cantus_id = get_suggested_chants(cantus_id="a07763")
+        with self.subTest(
+            test="Ensure None is returned in case of Cantus ID without suggestions"
+        ):
+            self.assertIsNone(suggested_chants_rare_cantus_id)
+
+    def test_get_json_from_ci_api(self):
+        with patch("requests.get", mock_requests_get):
+            json_nextchants_response = get_json_from_ci_api(
+                path="/json-nextchants/001010"
+            )
+        with self.subTest(
+            test="Ensure properly handles /nextchants/<cantus_id> endpoint"
+        ):
+            self.assertIsInstance(json_nextchants_response, list)
+            first_nextchant = json_nextchants_response[0]
+            self.assertIsInstance(first_nextchant, dict)
+
+        with patch("requests.get", mock_requests_get):
+            json_cid_response = get_json_from_ci_api(path="/json-cid/008349")
+        observed_json_cid_keys = json_cid_response.keys()
+        expected_json_cid_keys = ("info", "chants")
+        with self.subTest(
+            test="Ensure properly handles /json-cid/<cantus_id> endpoint"
+        ):
+            self.assertIsInstance(json_cid_response, dict)
+            for key in expected_json_cid_keys:
+                self.assertIn(key, observed_json_cid_keys)
+
+        with patch("requests.get", mock_requests_get):
+            response_short_timeout = get_json_from_ci_api(
+                path="/some/path", timeout=0.0001
+            )
+        with self.subTest(test="Ensure returns None when requests.get times out"):
+            self.assertIsNone(response_short_timeout)
+
+        with patch("requests.get", mock_requests_get):
+            response_nonexistent_cantus_id = get_json_from_ci_api(
+                path="/json-cid/notACantusID"
+            )
+        with self.subTest(
+            test="Ensure returns None when response status code is not 200"
+        ):
+            self.assertIsNone(response_nonexistent_cantus_id)
+
+        with self.subTest(
+            test="Ensure raises ValueError when path lacks leading slash"
+        ):
+            self.assertRaises(
+                ValueError, get_json_from_ci_api, "path/lacking/a/leading/slash"
+            )
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
@@ -13,6 +13,8 @@
 import csv
 from collections.abc import KeysView, ItemsView
 from typing import Optional
+from unittest.mock import patch
+from .test_functions import mock_requests_get
 
 from faker import Faker
 
@@ -2772,13 +2774,17 @@ def setUp(self):
     def test_url_and_templates(self):
         source = make_fake_source()
 
-        response = self.client.get(reverse("chant-create", args=[source.id]))
-        self.assertEqual(response.status_code, 200)
-        self.assertTemplateUsed(response, "chant_create.html")
+        with patch("requests.get", mock_requests_get):
+            response_1 = self.client.get(reverse("chant-create", args=[source.id]))
+            response_2 = self.client.get(
+                reverse("chant-create", args=[source.id + 100])
+            )
 
-        response = self.client.get(reverse("chant-create", args=[source.id + 100]))
-        self.assertEqual(response.status_code, 404)
-        self.assertTemplateUsed(response, "404.html")
+        self.assertEqual(response_1.status_code, 200)
+        self.assertTemplateUsed(response_1, "chant_create.html")
+
+        self.assertEqual(response_2.status_code, 404)
+        self.assertTemplateUsed(response_2, "404.html")
 
     def test_create_chant(self):
         source = make_fake_source()
@@ -2797,14 +2803,16 @@ def test_create_chant(self):
 
     def test_view_url_path(self):
         source = make_fake_source()
-        response = self.client.get(f"/chant-create/{source.id}")
+        with patch("requests.get", mock_requests_get):
+            response = self.client.get(f"/chant-create/{source.id}")
         self.assertEqual(response.status_code, 200)
 
     def test_context(self):
         """some context variable passed to templates"""
         source = make_fake_source()
         url = reverse("chant-create", args=[source.id])
-        response = self.client.get(url)
+        with patch("requests.get", mock_requests_get):
+            response = self.client.get(url)
         self.assertEqual(response.context["source"].title, source.title)
 
     def test_post_error(self):
@@ -2826,9 +2834,10 @@ def test_nonexistent_source(self):
         nonexistent_source_id: str = faker.numerify(
             "#####"
         )  # there's not supposed to be 5-digits source id
-        response = self.client.get(
-            reverse("chant-create", args=[nonexistent_source_id])
-        )
+        with patch("requests.get", mock_requests_get):
+            response = self.client.get(
+                reverse("chant-create", args=[nonexistent_source_id])
+            )
         self.assertEqual(response.status_code, 404)
 
     def test_repeated_seq(self):
@@ -2865,13 +2874,15 @@ def test_repeated_seq(self):
     def test_view_url_reverse_name(self):
         fake_sources = [make_fake_source() for _ in range(10)]
         for source in fake_sources:
-            response = self.client.get(reverse("chant-create", args=[source.id]))
+            with patch("requests.get", mock_requests_get):
+                response = self.client.get(reverse("chant-create", args=[source.id]))
             self.assertEqual(response.status_code, 200)
 
     def test_template_used(self):
         fake_sources = [make_fake_source() for _ in range(10)]
         for source in fake_sources:
-            response = self.client.get(reverse("chant-create", args=[source.id]))
+            with patch("requests.get", mock_requests_get):
+                response = self.client.get(reverse("chant-create", args=[source.id]))
             self.assertEqual(response.status_code, 200)
             self.assertTemplateUsed(response, "base.html")
             self.assertTemplateUsed(response, "chant_create.html")
@@ -2891,9 +2902,10 @@ def test_volpiano_signal(self):
                 # clefs, accidentals, etc., to be deleted
             },
         )
-        chant_1 = Chant.objects.get(
-            manuscript_full_text_std_spelling="ut queant lactose"
-        )
+        with patch("requests.get", mock_requests_get):
+            chant_1 = Chant.objects.get(
+                manuscript_full_text_std_spelling="ut queant lactose"
+            )
         self.assertEqual(chant_1.volpiano, "9abcdefg)A-B1C2D3E4F5G67?. yiz")
         self.assertEqual(chant_1.volpiano_notes, "9abcdefg9abcdefg")
         self.client.post(
@@ -2905,7 +2917,10 @@ def test_volpiano_signal(self):
                 "volpiano": "abacadaeafagahaja",
             },
         )
-        chant_2 = Chant.objects.get(manuscript_full_text_std_spelling="resonare foobaz")
+        with patch("requests.get", mock_requests_get):
+            chant_2 = Chant.objects.get(
+                manuscript_full_text_std_spelling="resonare foobaz"
+            )
         self.assertEqual(chant_2.volpiano, "abacadaeafagahaja")
         self.assertEqual(chant_2.volpiano_intervals, "1-12-23-34-45-56-67-78-8")
 
@@ -2928,12 +2943,12 @@ def test_initial_values(self):
                 "image_link": image_link,
             },
         )
-
-        # when we request the Chant Create page, the same folio, feast, office and image_link should
-        # be preselected, and c_sequence should be incremented by 1.
-        response = self.client.get(
-            reverse("chant-create", args=[source.id]),
-        )
+        with patch("requests.get", mock_requests_get):
+            # when we request the Chant Create page, the same folio, feast, office and image_link should
+            # be preselected, and c_sequence should be incremented by 1.
+            response = self.client.get(
+                reverse("chant-create", args=[source.id]),
+            )
 
         observed_initial_folio: int = response.context["form"].initial["folio"]
         with self.subTest(subtest="test initial value of folio field"):
@@ -2955,6 +2970,50 @@ def test_initial_values(self):
         with self.subTest(subtest="test initial value of image_link field"):
             self.assertEqual(observed_initial_image, image_link)
 
+    def test_suggested_chant_buttons(self):
+        source: Source = make_fake_source()
+        with patch("requests.get", mock_requests_get):
+            response_empty_source = self.client.get(
+                reverse("chant-create", args=[source.id]),
+            )
+        with self.subTest(
+            test="Ensure no suggestions displayed when there is no previous chant"
+        ):
+            self.assertNotContains(
+                response_empty_source, "Suggestions based on previous chant:"
+            )
+
+        previous_chant: Chant = make_fake_chant(cantus_id="001010", source=source)
+        with patch("requests.get", mock_requests_get):
+            response_after_previous_chant = self.client.get(
+                reverse("chant-create", args=[source.id]),
+            )
+        suggested_chants = response_after_previous_chant.context["suggested_chants"]
+        with self.subTest(
+            test="Ensure suggested chant suggestions present when previous chant exists"
+        ):
+            self.assertContains(
+                response_after_previous_chant, "Suggestions based on previous chant:"
+            )
+            self.assertIsNotNone(suggested_chants)
+            self.assertEqual(len(suggested_chants), 3)
+
+        rare_chant: Chant = make_fake_chant(cantus_id="a07763", source=source)
+        with patch("requests.get", mock_requests_get):
+            response_after_rare_chant = self.client.get(
+                reverse("chant-create", args=[source.id]),
+            )
+        with self.subTest(
+            test="When previous chant has no suggested chants, ensure no suggestions are displayed"
+        ):
+            self.assertContains(
+                response_after_rare_chant, "Suggestions based on previous chant:"
+            )
+            self.assertContains(
+                response_after_rare_chant, "Sorry! No suggestions found."
+            )
+            self.assertIsNone(response_after_rare_chant.context["suggested_chants"])
+
 
 class ChantDeleteViewTest(TestCase):
     @classmethod