Skip to content

Commit

Permalink
Merge pull request #1720 from dchiller/i1389-reimplement-concordances
Browse files Browse the repository at this point in the history
  • Loading branch information
dchiller authored Dec 2, 2024
2 parents dbcdbd3 + 62b409c commit 1237c89
Show file tree
Hide file tree
Showing 11 changed files with 1,127 additions and 559 deletions.
408 changes: 253 additions & 155 deletions django/cantusdb_project/main_app/templates/chant_detail.html

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions django/cantusdb_project/main_app/tests/mock_cantusindex_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
"field_similar_chant_id": null,
"field_troped_chant_id": null
},
"databases": {
"PEM": {
"count": 2,
"name": "Portuguese Early Music Database",
"url": "https://pemdatabase.eu",
"url_cid": "https://pemdatabase.eu/musical-items?cid=008349",
"last_update": ""
}
},
"chants": {
"0": {
"siglum": "P-BRs Ms. 032",
Expand Down Expand Up @@ -131,6 +140,15 @@
"field_similar_chant_id": null,
"field_troped_chant_id": null
},
"databases": {
"PEM": {
"count": 2,
"name": "Portuguese Early Music Database",
"url": "https://pemdatabase.eu",
"url_cid": "https://pemdatabase.eu/musical-items?cid=006928",
"last_update": ""
}
},
"chants": {
"0": {
"siglum": "P-BRs Ms. 032",
Expand Down
71 changes: 70 additions & 1 deletion django/cantusdb_project/main_app/tests/test_views/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"""

import json
from typing import Optional
from typing import Optional, Any
import csv
from collections.abc import ItemsView, KeysView
from unittest.mock import patch, MagicMock

from django.test import TestCase
from django.urls import reverse
Expand All @@ -21,6 +22,11 @@
)
from main_app.models import Chant, Source, Provenance, Notation

from main_app.tests.mock_cantusindex_data import (
mock_json_cid_008349_json,
mock_json_cid_006928_json,
)


class AjaxSearchBarTest(TestCase):
def test_response(self):
Expand Down Expand Up @@ -986,3 +992,66 @@ def test_csv_export_on_source_with_sequences(self):
):
for row in rows:
self.assertNotEqual(row[3], "")


cid_concordances_mock_requests_data = {
"https://cantusindex.uwaterloo.ca/json-cid/008349": MagicMock(
**{
"json.return_value": mock_json_cid_008349_json,
"status_code": 200,
"text.strip.return_value": 1, # Set this so cantusindex.get_json_from_ci_api
# does not return None
}
),
"https://cantusindex.uwaterloo.ca/json-cid/006928": MagicMock(
**{
"json.return_value": mock_json_cid_006928_json,
"text.strip.return_value": 1,
"status_code": 200,
},
),
"https://cantusindex.uwaterloo.ca/json-cid/000000": MagicMock(
**{
"json.return_value": {"databases": {}, "chants": {}},
"text.strip.return_value": 1,
"status_code": 200,
},
),
"https://gregorien.info/chant/cid/008349/en": MagicMock(status_code=404),
"https://gregorien.info/chant/cid/006928/en": MagicMock(status_code=200),
"https://gregorien.info/chant/cid/000000/en": MagicMock(status_code=404),
}


def cid_concordances_requests_value(url: str, timeout: int) -> dict[str, Any]:
return cid_concordances_mock_requests_data[url]


class CIDConcordancesTest(TestCase):
# A dictionary containing the data expected from the API
# calls made by the view. The keys are the URLs of the API
# calls, and the values are the data returned by the API.

@patch("requests.get", MagicMock(side_effect=cid_concordances_requests_value))
def test_view(self) -> None:
with self.subTest("Concordances exist on Cantus Index and Gregorien"):
response = self.client.get(
reverse("cid-concordances"), data={"cantus_id": "006928"}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["databases"]), 2)
self.assertEqual(len(response.json()["chants"]), 2)
with self.subTest("Concordances exist on Cantus Index but not Gregorien"):
response = self.client.get(
reverse("cid-concordances"), data={"cantus_id": "008349"}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["databases"]), 1)
self.assertEqual(len(response.json()["chants"]), 2)
with self.subTest("No concordances"):
response = self.client.get(
reverse("cid-concordances"), data={"cantus_id": "000000"}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()["databases"]), 0)
self.assertEqual(len(response.json()["chants"]), 0)
6 changes: 6 additions & 0 deletions django/cantusdb_project/main_app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
csv_export,
articles_list_export,
flatpages_list_export,
cid_concordances,
)
from main_app.views.institution import InstitutionListView, InstitutionDetailView
from main_app.views.redirect import (
Expand Down Expand Up @@ -568,6 +569,11 @@
DifferentiaAutocomplete.as_view(),
name="differentia-autocomplete",
),
path(
"cid-concordances/",
cid_concordances,
name="cid-concordances",
),
]

if settings.DEBUG:
Expand Down
46 changes: 46 additions & 0 deletions django/cantusdb_project/main_app/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from django.http import HttpResponse, HttpResponseNotFound, Http404, HttpRequest
from django.urls.base import reverse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_GET
import requests
from requests.exceptions import Timeout, ConnectionError
from articles.models import Article
from main_app.models import (
Chant,
Expand All @@ -18,6 +21,7 @@
Source,
)
from next_chants import next_chants
from cantusindex import get_json_from_ci_api


def ajax_melody_list(request: HttpRequest, cantus_id: str) -> JsonResponse:
Expand Down Expand Up @@ -668,3 +672,45 @@ def flatpages_list_export(request) -> HttpResponse:
for flatpage in flatpages
]
return HttpResponse(" ".join(flatpage_urls), content_type="text/plain")


@require_GET
def cid_concordances(request: HttpRequest) -> JsonResponse:
"""
View that proxies a request initiated from the Chant Detail page
to Cantus Index and Gregorien.info for the chant concordances for
a given Cantus ID. A proxy is required due to the CORS setup on Cantus
Index and because Gregorien.info does not have an API (we just check
whether there are concordances and then display a link to the chant).
Returns:
The JSON response to the json-cid endpoint on Cantus Index.
The resulting object should have three keys:
- info: an object with common information on the Cantus ID.
- databases: an object with summary information on concordances
in each database.
- chants: an object with the actual concordances.
"""
cantus_id = request.GET.get("cantus_id")
if not cantus_id:
return JsonResponse({"error": "No Cantus ID provided."}, status=400)
concordances_dict = get_json_from_ci_api(f"/json-cid/{cantus_id}")
# If no concordances are found, the API returns an empty list.
if concordances_dict == []:
concordances_dict = {"databases": {}, "chants": {}}
try:
gregorien_response = requests.get(
f"https://gregorien.info/chant/cid/{cantus_id}/en",
timeout=5,
)
except (ConnectionError, Timeout):
gregorien_response = None
if gregorien_response and gregorien_response.status_code == 200:
gregorien_database_dict = {
"name": "Gregorien.info",
"url": "https://gregorien.info/",
"url_cid": f"https://gregorien.info/chant/cid/{cantus_id}/en",
"count": None,
}
concordances_dict["databases"]["GRG"] = gregorien_database_dict
return JsonResponse(concordances_dict)
150 changes: 150 additions & 0 deletions django/cantusdb_project/static/js/chant_detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,153 @@ function loadMelodies(cantusId) {
melodyButton.style.display = "none";
}

function concordanceDetailToggleText(event) {
const collapseLink = event.target;
if (collapseLink.getAttribute("aria-expanded") === "true") {
collapseLink.innerHTML = "▶ Show concordance details";
} else {
collapseLink.innerHTML = "▼ Hide concordance details";
}
}

function getConcordances(cantusID) {
// Remove the concordance button
const concordanceButton = document.getElementById("concordanceButton");
concordanceButton.classList.add("d-none");
// Display the concordance loading status
const concordancesLoadingStatus = document.getElementById("concordancesLoadingStatus");
concordancesLoadingStatus.classList.remove("d-none");

const concordancesDiv = document.getElementById("concordancesDiv");
const concordancesUrl = new URL(`/cid-concordances/`, window.location.origin);
concordancesUrl.searchParams.set("cantus_id", cantusID);
const xhttp = new XMLHttpRequest();
xhttp.open("GET", concordancesUrl);
xhttp.onload = function () {
// Remove the concordance loading status
const concordancesLoadingStatus = document.getElementById("concordancesLoadingStatus");
concordancesLoadingStatus.classList.add("d-none");
const concordancesData = JSON.parse(this.response);
if (Object.keys(concordancesData.databases).length === 0) {
const noConcordances = document.createElement("p");
noConcordances.innerHTML = `No concordances found for Cantus ID <b><a href="http://cantusindex.org/id/${cantusID}" target="_blank" title="${cantusID} on Cantus Index"> ${cantusID}</a></b>.`;
concordancesDiv.appendChild(noConcordances);
return;
}
// Create the concordances summary table
const concordancesSummaryTable = document.createElement("table");
concordancesSummaryTable.id = "concordancesSummaryTable";
concordancesSummaryTable.setAttribute("class", "mx-3 table table-sm col-8 small border-bottom");
const summaryTableHeader = concordancesSummaryTable.createTHead();
const summaryHeaderRow = summaryTableHeader.insertRow();
const summaryHeaderCell = summaryHeaderRow.insertCell();
summaryHeaderCell.classList.add("border-top-0");
summaryHeaderCell.innerHTML = "<b>Summary</b>";
for (const [initialism, dbSummary] of Object.entries(concordancesData.databases)) {
// object returned by json-con API is ordered by the number
// of concordances in descending order
const newRow = concordancesSummaryTable.insertRow();
// The first cell of each row contains the database name
// and initialism with a link to the database home page
const dbNameCell = newRow.insertCell();
const dbNameLink = document.createElement("a");
dbNameLink.setAttribute("href", dbSummary.url);
dbNameLink.setAttribute("target", "_blank");
const dbNameLinkText = document.createTextNode(`${dbSummary.name} (${initialism})`);
dbNameLink.appendChild(dbNameLinkText);
dbNameCell.appendChild(dbNameLink);
// The second cell of each row contains the number of concordances
// linked to the results url on the database
const concordanceCountCell = newRow.insertCell();
const concordanceCountLink = document.createElement("a");
concordanceCountLink.setAttribute("href", dbSummary.url_cid);
concordanceCountLink.setAttribute("target", "_blank");
if (dbSummary.count > 1) {
concordanceCountLink.appendChild(document.createTextNode(`${dbSummary.count} concordances`));
} else if (dbSummary.count === 1) {
concordanceCountLink.appendChild(document.createTextNode(`${dbSummary.count} concordance`));
} else {
concordanceCountLink.appendChild(document.createTextNode(`See concordance(s)`));
}
concordanceCountCell.appendChild(concordanceCountLink);
}
const concordancesSummaryRow = document.createElement("div");
concordancesSummaryRow.setAttribute("class", "row");
concordancesSummaryRow.appendChild(concordancesSummaryTable);
concordancesDiv.appendChild(concordancesSummaryRow);
if (Object.keys(concordancesData.chants).length === 0) {
return;
}
// Create table collapse link
const collapseLink = document.createElement("a");
collapseLink.classList.add("mx-3");
collapseLink.setAttribute("id", "concordanceDetailCollapseLink");
collapseLink.setAttribute("data-toggle", "collapse");
collapseLink.setAttribute("href", "#concordancesDetailTable");
collapseLink.setAttribute("role", "button");
collapseLink.setAttribute("aria-expanded", "true");
collapseLink.setAttribute("aria-controls", "concordancesDetailTable");
collapseLink.innerHTML = "▼ Hide concordance details";
collapseLink.addEventListener("click", concordanceDetailToggleText);
// Create the concordances detail table
const concordancesTable = document.createElement("table");
concordancesTable.id = "concordancesDetailTable";
concordancesTable.setAttribute("class", "mx-3 mt-3 table table-sm table-responsive table-bordered small collapse show");
const headerArray = ["Source", "Incipit", "Office | Genre | Position", "Feast", "Mode", "Database"];
const headerRow = concordancesTable.createTHead().insertRow();
for (const header of headerArray) {
const headerCell = headerRow.insertCell();
headerCell.classList.add("text-center");
headerCell.innerHTML = `<b>${header}</b>`;
}
var concordancesDetail = Object.values(concordancesData.chants);
concordancesDetail.sort((a, b) => {
return a.siglum.localeCompare(b.siglum);
});
for (const concordance of concordancesDetail) {
const newRow = concordancesTable.insertRow();
const sourceLink = document.createElement("a");
sourceLink.setAttribute("href", concordance.srclink);
sourceLink.setAttribute("target", "_blank");
sourceLink.innerHTML = `${concordance.siglum}`;
const folio = document.createElement("span");
folio.innerHTML = `, <b>${concordance.folio}</b>`;
// Some concordance image links are invalid urls (e.g. "0")
// so we need to check both that the link is not empty and that it is
// a valid url before creating the image link
try {
const url = new URL(concordance.image);
var imageLink = document.createElement("a");
imageLink.setAttribute("href", url.href);
imageLink.setAttribute("target", "_blank");
imageLink.classList.add("bi-camera-fill");
newRow.insertCell().innerHTML = sourceLink.outerHTML + folio.outerHTML + "<br>" + imageLink.outerHTML;
} catch (e) {
newRow.insertCell().innerHTML = sourceLink.outerHTML + folio.outerHTML;
}
const chantLink = document.createElement("a");
chantLink.setAttribute("href", concordance.chantlink);
chantLink.setAttribute("target", "_blank");
chantLink.innerHTML = concordance.incipit;
newRow.insertCell().innerHTML = chantLink.outerHTML;
newRow.insertCell().innerHTML = `${concordance.office} | ${concordance.genre} | ${concordance.position}`;
newRow.insertCell().innerHTML = concordance.feast;
newRow.insertCell().innerHTML = concordance.mode;
newRow.insertCell().innerHTML = concordance.db;
}
const concordancesRow = document.createElement("div");
concordancesRow.setAttribute("class", "row");
concordancesRow.appendChild(collapseLink);
concordancesRow.appendChild(concordancesTable);
concordancesDiv.appendChild(concordancesRow);


}
xhttp.onerror = function () {
concordancesLoadingStatus.innerHTML = "";
const concordancesLoadError = document.createElement("p");
concordancesLoadError.innerHTML = `Cantus Database encountered an error while loading concordances (Cantus ID <b><a href="http://cantusindex.org/id/${cantusID}" target="_blank" title="${cantusID} on Cantus Index"> ${cantusID} </a></b>).`;
concordancesLoadingStatus.appendChild(concordancesLoadError);
}
xhttp.send();
}
2 changes: 1 addition & 1 deletion django/cantusdb_project/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ body {

h1 {
font-weight: 500;
}
}
6 changes: 5 additions & 1 deletion django/cantusdb_project/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@
text-decoration: underline;
}

.link-color {
color: #922
}

.footer h5 {
margin: 25px 0px 10px
}
Expand Down Expand Up @@ -282,7 +286,7 @@
</header>

<!-- Content goes here, using the extends tag from Django -->
<main role="main" class="content container px-0 pt-4">
<main role="main" class="content container pt-4">
{% block content %}
{% endblock %}
</main>
Expand Down
Loading

0 comments on commit 1237c89

Please sign in to comment.