Repository: DDMAL/CantusDB

Issue:
`json-cid` API
OldCantus has a `json-cid` API: https://cantus.uwaterloo.ca/json-cid/006583a .

The format of the data it returns looks very similar to that of CantusIndex's `json-con` API. In OldCantus's source code, there are numerous calls to other databases' `json-cid` pages, so it's likely that CI uses these APIs for compiling the data in its `json-con` API. So, we'll probably have to implement it if we want CI to be able to display concordances from CD.


Discussion:
I think we should assume that this API is important, since OldCantus has it, and so we should be sure to implement it. Going to apply an "important" label, because we should really have this set up before NewCantus goes live.

Resolving patch:
diff --git a/django/cantusdb_project/main_app/urls.py b/django/cantusdb_project/main_app/urls.py
--- a/django/cantusdb_project/main_app/urls.py
+++ b/django/cantusdb_project/main_app/urls.py
@@ -317,6 +317,11 @@
         views.json_melody_export,
         name="json-melody-export",
     ),
+    path(
+        "json-cid/<str:cantus_id>",
+        views.json_cid_export,
+        name="json-cid-export",
+    ),
     # misc search
     path(
         "searchms/<int:source_pk>",
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
@@ -28,6 +28,7 @@
 from django.core.exceptions import PermissionDenied
 from django.urls import reverse
 from django.contrib.auth import get_user_model
+from typing import List
 
 
 @login_required
@@ -434,7 +435,7 @@ def ajax_search_bar(request, search_term):
     return JsonResponse({"chants": returned_values}, safe=True)
 
 
-def json_melody_export(request, cantus_id):
+def json_melody_export(request, cantus_id: str) -> JsonResponse:
     chants = Chant.objects.filter(
         cantus_id=cantus_id, volpiano__isnull=False, source__published=True
     )
@@ -460,48 +461,63 @@ def json_melody_export(request, cantus_id):
     chants_values = list(chants.values(*db_keys))  # a list of dictionaries. Each
     # dictionary represents metadata on one chant
 
-    def standardize_for_api(chant_values):
-        keymap = {  # map attribute names from Chant model (i.e. db_keys
-            # in list above) to corresponding attribute names
-            # in old API, and remove artifacts of query process (i.e. __id suffixes)
-            "melody_id": "mid",  #                  <-
-            "id": "nid",  #                         <-
-            "cantus_id": "cid",  #                  <-
-            "siglum": "siglum",
-            "source__id": "srcnid",  #              <-
-            "folio": "folio",
-            "incipit": "incipit",
-            "manuscript_full_text": "fulltext",  #  <-
-            "volpiano": "volpiano",
-            "mode": "mode",
-            "feast__id": "feast",  #                <-
-            "office__id": "office",  #              <-
-            "genre__id": "genre",  #                <-
-            "position": "position",
-        }
-
-        standardized_chant_values = {
-            keymap[key]: chant_values[key] for key in chant_values
-        }
-
-        # manually build a couple of last fields that aren't represented in Chant object
-        chant_uri = request.build_absolute_uri(
-            reverse("chant-detail", args=[chant_values["id"]])
-        )
-        standardized_chant_values["chantlink"] = chant_uri
-        src_uri = request.build_absolute_uri(
-            reverse("source-detail", args=[chant_values["source__id"]])
-        )
-        standardized_chant_values["srclink"] = src_uri
+    standardized_chants_values = [
+        standardize_dict_for_json_melody_export(cv, request) for cv in chants_values
+    ]
 
-        return standardized_chant_values
+    return JsonResponse(standardized_chants_values, safe=False)
 
-    standardized_chants_values = [standardize_for_api(cv) for cv in chants_values]
 
-    return JsonResponse(standardized_chants_values, safe=False)
+def standardize_dict_for_json_melody_export(
+    chant_values: List[dict], request
+) -> List[dict]:
+    """Take a list of dictionaries, and in each dictionary, change several
+    of the keys to match their values in OldCantus
+
+    Args:
+        chant_values (List[dict]): a list of dictionaries, each containing
+            information on a single chant in the database
+        request: passed when this is called in json_melody_export. Used to get the domain
+            while building the chant links
+
+    Returns:
+        List[dict]: a list of dictionaries, with updated keys
+    """
+    keymap = {  # map attribute names from Chant model (i.e. db_keys
+        # in json_melody_export) to corresponding attribute names
+        # in old API, and remove artifacts of query process (i.e. __id suffixes)
+        "melody_id": "mid",  #                  <-
+        "id": "nid",  #                         <-
+        "cantus_id": "cid",  #                  <-
+        "siglum": "siglum",
+        "source__id": "srcnid",  #              <-
+        "folio": "folio",
+        "incipit": "incipit",
+        "manuscript_full_text": "fulltext",  #  <-
+        "volpiano": "volpiano",
+        "mode": "mode",
+        "feast__id": "feast",  #                <-
+        "office__id": "office",  #              <-
+        "genre__id": "genre",  #                <-
+        "position": "position",
+    }
+
+    standardized_chant_values = {keymap[key]: chant_values[key] for key in chant_values}
+
+    # manually build a couple of last fields that aren't represented in Chant object
+    chant_uri = request.build_absolute_uri(
+        reverse("chant-detail", args=[chant_values["id"]])
+    )
+    standardized_chant_values["chantlink"] = chant_uri
+    src_uri = request.build_absolute_uri(
+        reverse("source-detail", args=[chant_values["source__id"]])
+    )
+    standardized_chant_values["srclink"] = src_uri
 
+    return standardized_chant_values
 
-def json_node_export(request, id):
+
+def json_node_export(request, id: int) -> JsonResponse:
     """
     returns all fields of the chant/sequence/source/indexer with the specified `id`
     """
@@ -532,25 +548,31 @@ def json_node_export(request, id):
     return JsonResponse(vals)
 
 
-def json_sources_export(request):
+def json_sources_export(request) -> JsonResponse:
     """
-    generates a json object of published sources with their IDs and CSV links
+    Generate a json object of published sources with their IDs and CSV links
     """
     sources = Source.objects.filter(published=True)
     ids = [source.id for source in sources]
 
-    def inner_dictionary(id):
-        # in OldCantus, json-sources creates a json file with each id attribute pointing to a dictionary
-        # containing a single key, "csv", which itself points to a link to the relevant csv file.
-        # inner_dictionary() is used to build this single-keyed dictionary.
+    csv_links = {id: build_json_sources_export_dictionary(id, request) for id in ids}
 
-        # To avoid confusion, note that the `id` parameter refers to an identifier, and is not
-        # an abbreviation of `inner_dictionary`!
-        return {"csv": request.build_absolute_uri(reverse("csv-export", args=[id]))}
+    return JsonResponse(csv_links)
 
-    csv_links = {id: inner_dictionary(id) for id in ids}
 
-    return JsonResponse(csv_links)
+def build_json_sources_export_dictionary(id: int, request) -> dict:
+    """Return a dictionary containing a link to the csv-export page for a source
+
+    Args:
+        id (int): the pk of the source
+        request: passed when this is called in json_sources_export. Used to get the domain
+            while building the CSV link
+
+    Returns:
+        dict: a dictionary with a single key, "csv", and a link to the source's csv-export
+            page
+    """
+    return {"csv": request.build_absolute_uri(reverse("csv-export", args=[id]))}
 
 
 def json_nextchants(request, cantus_id):
@@ -559,6 +581,75 @@ def json_nextchants(request, cantus_id):
     return JsonResponse(suggested_chants_dict)
 
 
+def json_cid_export(request, cantus_id: str) -> JsonResponse:
+    """Return a JsonResponse containing information on all chants with a given
+    Cantus ID, in the following format:
+    {
+        "chants": [
+            "chant": {
+                a bunch of keys, created in build_json_cid_dictionary
+            },
+            "chant": {
+                etc.
+            },
+        ]
+    }
+    We believe Cantus Index uses this API in building its list of concordances
+    for a given Cantus ID across the databases in the Cantus Network
+
+    Args:
+        request: the incoming request
+        cantus_id (string): A Cantus ID
+    """
+
+    # the API in OldCantus appears to only return chants, and no sequences.
+    chants = Chant.objects.filter(cantus_id=cantus_id).filter(source__published=True)
+    chant_dicts = [{"chant": build_json_cid_dictionary(c, request)} for c in chants]
+    response = {"chants": chant_dicts}
+    return JsonResponse(response)
+
+
+def build_json_cid_dictionary(chant, request) -> dict:
+    """Return a dictionary with information on a given chant in the database
+
+    Args:
+        chant: a Chant
+        request: passed when this is called in json_cid_export. Used to get the domain
+            while building the chant link
+
+    Returns:
+        dict: a dictionary with information about the chant and its source, including
+            absolute URLs for the chant and source detail pages
+    """
+    source_relative_url = reverse("source-detail", args=[chant.source.id])
+    source_absolute_url = request.build_absolute_uri(source_relative_url)
+    chant_relative_url = reverse("chant-detail", args=[chant.id])
+    chant_absolute_url = request.build_absolute_uri(chant_relative_url)
+    dictionary = {
+        "siglum": chant.source.siglum,
+        "srclink": source_absolute_url,
+        "chantlink": chant_absolute_url,
+        # "chantlinkOLD":  # OldCantus included a URL using http:// here,
+        #                  # whereas "chantlink" had a URL with https://
+        "folio": chant.folio if chant.folio else "",
+        "incipit": chant.incipit if chant.incipit else "",
+        "feast": chant.feast.name if chant.feast else "",
+        "genre": chant.genre.name if chant.genre else "",
+        "office": chant.office.name if chant.office else "",
+        "position": chant.position if chant.position else "",
+        "mode": chant.mode if chant.mode else "",
+        "image": chant.image_link if chant.image_link else "",
+        "melody": chant.volpiano if chant.volpiano else "",
+        "fulltext": (
+            chant.manuscript_full_text_std_spelling
+            if chant.manuscript_full_text_std_spelling
+            else ""
+        ),
+        "db": "CD",
+    }
+    return dictionary
+
+
 def handle404(request, exception):
     return render(request, "404.html")
 


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
@@ -3716,6 +3716,183 @@ def test_published_vs_unpublished(self):
         self.assertEqual(unpacked_response_2, {"2000": 2})
 
 
+class JsonCidTest(TestCase):
+    def test_published_vs_unpublished(self):
+        published_source = make_fake_source(published=True)
+        published_chant = make_fake_chant(
+            cantus_id="123.publ",
+            source=published_source,
+        )
+        pub_response = self.client.get(
+            reverse("json-cid-export", args=["123.publ"]),
+        )
+        pub_json = pub_response.json()
+        pub_chants = pub_json["chants"]
+        self.assertEqual(len(pub_chants), 1)
+
+        unpublished_source = make_fake_source(published=False)
+        unpublished_chant = make_fake_chant(
+            cantus_id="456.unpub",
+            source=unpublished_source,
+        )
+        unpub_response = self.client.get(
+            reverse("json-cid-export", args=["456.unpub"]),
+        )
+        unpub_json = unpub_response.json()
+        unpub_chants = unpub_json["chants"]
+        self.assertEqual(len(unpub_chants), 0)
+
+    def test_chant_vs_sequence(self):
+        chant = make_fake_chant(cantus_id="123456")
+        response_1 = self.client.get(
+            reverse("json-cid-export", args=["123456"]),
+        )
+        json_1 = response_1.json()
+        chants_1 = json_1["chants"]
+        self.assertEqual(len(chants_1), 1)
+
+        sequence = make_fake_sequence(cantus_id="123456")
+        response_2 = self.client.get(
+            reverse("json-cid-export", args=["123456"]),
+        )
+        json_2 = response_2.json()
+        chants_2 = json_2["chants"]
+        self.assertEqual(
+            len(chants_2), 1
+        )  # should return the chant, but not the sequence
+
+        chant.delete()
+        response_3 = self.client.get(
+            reverse("json-cid-export", args=["123456"]),
+        )
+        json_3 = response_3.json()
+        chants_3 = json_3["chants"]
+        self.assertEqual(len(chants_3), 0)  # should not return the sequence
+
+    def test_structure(self):
+        """
+        should be structured thus:
+        {
+            "chants": [
+                "chant": {
+                    "siglum": "some value"
+                    "srclink": "some value"
+                    "chantlink": "some value"
+                    "folio": "some value"
+                    "incipit": "some value"
+                    "feast": "some value"
+                    "genre": "some value"
+                    "office": "some value"
+                    "position": "some value"
+                    "mode": "some value"
+                    "image": "some value"
+                    "melody": "some value"
+                    "fulltext": "some value"
+                    "db": "CD"
+                },
+                "chant": {
+                    etc.
+                },
+            ]
+        }
+        """
+        for _ in range(7):
+            make_fake_chant(cantus_id="3.14159")
+        response = self.client.get(
+            reverse("json-cid-export", args=["3.14159"]),
+        )
+        json_obj = response.json()
+        json_keys = json_obj.keys()
+        self.assertEqual(list(json_keys), ["chants"])
+
+        chants = json_obj["chants"]
+        self.assertIsInstance(chants, list)
+        self.assertEqual(len(chants), 7)
+
+        first_item = chants[0]
+        item_keys = first_item.keys()
+        self.assertIsInstance(first_item, dict)
+        self.assertEqual(list(item_keys), ["chant"])
+
+        first_chant = first_item["chant"]
+        chant_keys = first_chant.keys()
+        expected_keys = [
+            "siglum",
+            "srclink",
+            "chantlink",
+            "folio",
+            "incipit",
+            "feast",
+            "genre",
+            "office",
+            "position",
+            "mode",
+            "image",
+            "melody",
+            "fulltext",
+            "db",
+        ]
+        self.assertEqual(list(chant_keys), expected_keys)
+
+    def test_values(self):
+        chant = make_fake_chant(cantus_id="100000")
+        expected_values = {
+            "siglum": chant.source.siglum,
+            "folio": chant.folio,
+            "incipit": chant.incipit,
+            "feast": chant.feast.name,
+            "genre": chant.genre.name,
+            "office": chant.office.name,
+            "position": chant.position,
+            "mode": chant.mode,
+            "image": chant.image_link,
+            "melody": chant.volpiano,
+            "fulltext": chant.manuscript_full_text_std_spelling,
+            "db": "CD",
+        }
+        response_1 = self.client.get(
+            reverse("json-cid-export", args=["100000"]),
+        )
+        json_for_one_chant_1 = response_1.json()["chants"][0]["chant"]
+        for key in expected_values.keys():
+            self.assertEqual(expected_values[key], json_for_one_chant_1[key])
+
+        chant.manuscript_full_text = None
+        chant.manuscript_full_text_std_spelling = None
+        chant.folio = None
+        chant.incipit = None
+        chant.feast = None
+        chant.genre = None
+        chant.office = None
+        chant.position = None
+        chant.mode = None
+        chant.image_link = None
+        chant.volpiano = None
+        chant.manuscript_full_text_std_spelling = None
+        chant.save()
+
+        response_2 = self.client.get(
+            reverse("json-cid-export", args=["100000"]),
+        )
+        json_for_one_chant_2 = response_2.json()["chants"][0]["chant"]
+        for item in json_for_one_chant_2.items():
+            try:
+                assert isinstance(item[1], str)
+            except AssertionError:
+                print(item)
+
+            self.assertIsInstance(item[1], str)  # we shouldn't see any Nones or nulls
+
+        chant.manuscript_full_text = "nahn-staendrd spillynge"
+        chant.manuscript_full_text_std_spelling = "standard spelling"
+        chant.save()
+        response_3 = self.client.get(
+            reverse("json-cid-export", args=["100000"]),
+        )
+        json_for_one_chant_3 = response_3.json()["chants"][0]["chant"]
+        self.assertEqual(json_for_one_chant_3["fulltext"], "standard spelling")
+
+
 class CISearchViewTest(TestCase):
     def test_view_url_path(self):
         fake_search_term = faker.word()