Skip to content

Commit

Permalink
Merge pull request #1730 from dchiller/i1729-source-chant-json-endpoint
Browse files Browse the repository at this point in the history
Add JSON response to chant and source detail endpoints
  • Loading branch information
dchiller authored Jan 17, 2025
2 parents 8c751fc + ab8096a commit 0938ad1
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 42 deletions.
21 changes: 20 additions & 1 deletion django/cantusdb_project/main_app/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ class JSONResponseMixin:
lists the fields to be included in the JSON response.
"""

def _get_field(self, obj: Any, field: str) -> Any:
"""
Returns the value of a field on an object, allowing
for "double underscore" notation to access related
fields.
Returns the value specified by the double underscore
lookup. If this lookup fails, return the object as
far down the lookup chain as possible.
"""
split_field = field.split("__")
temp_obj = obj
for part in split_field:
try:
temp_obj = getattr(temp_obj, part)
except AttributeError:
return temp_obj
return temp_obj

def render_to_response(
self, context: dict[Any, Any], **response_kwargs: dict[Any, Any]
) -> HttpResponse:
Expand All @@ -43,7 +62,7 @@ def render_to_response(
if obj:
obj_json = {}
for field in json_fields:
obj_json[field] = getattr(obj, field)
obj_json[field] = self._get_field(obj, field)
return JsonResponse({obj.get_verbose_name(): obj_json})
q_s = context["object_list"].values(*json_fields)
q_s_name = str(q_s.model.get_verbose_name_plural())
Expand Down
8 changes: 4 additions & 4 deletions django/cantusdb_project/main_app/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def user_can_proofread_chant(user: User, chant: Chant) -> bool:
return user_can_proofread_source(user, source)


def user_can_proofread_source(user: User, source: Source) -> bool:
def user_can_proofread_source(user: Union[User, AnonymousUser], source: Source) -> bool:
"""
Checks if the user can access the proofreading page of a given Source.
Used in SourceBrowseChantsView.
Expand All @@ -81,7 +81,7 @@ def user_can_proofread_source(user: User, source: Source) -> bool:
return user_is_pm or (user_is_editor and user_is_assigned_to_source)


def user_can_view_source(user: User, source: Source) -> bool:
def user_can_view_source(user: Union[User, AnonymousUser], source: Source) -> bool:
"""
Checks if the user can view an unpublished Source on the site.
Used in ChantDetail, SequenceDetail, and SourceDetail views.
Expand Down Expand Up @@ -149,7 +149,7 @@ def user_can_create_sources(user: User) -> bool:
).exists()


def user_can_edit_source(user: User, source: Source) -> bool:
def user_can_edit_source(user: Union[User, AnonymousUser], source: Source) -> bool:
"""
Checks if the user has permission to edit a Source object.
Used in SourceDetail, SourceEdit, and SourceDelete views.
Expand Down Expand Up @@ -182,7 +182,7 @@ def user_can_view_user_detail(viewing_user: User, user: User) -> bool:
return viewing_user.is_authenticated or user.is_indexer


def user_can_manage_source_editors(user: User) -> bool:
def user_can_manage_source_editors(user: Union[User, AnonymousUser]) -> bool:
"""
Checks if the user has permission to change the editors assigned to a Source.
Used in SourceDetailView.
Expand Down
61 changes: 35 additions & 26 deletions django/cantusdb_project/main_app/tests/test_views/test_chant.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,31 @@
from main_app.tests.test_functions import mock_requests_get
from main_app.models import Chant, Source, Feast, Service
from main_app.views.chant import get_feast_selector_options
from users.models import User as UserAnnotation


# Create a Faker instance with locale set to Latin
faker = Faker("la")


class ChantDetailViewTest(TestCase):
pm_group: ClassVar[Group]
pm_user: ClassVar[UserAnnotation]

@classmethod
def setUpTestData(cls):
Group.objects.create(name="project manager")
def setUpTestData(cls) -> None:
cls.pm_group = Group.objects.create(name="project manager")
cls.pm_user = get_user_model().objects.create(email="pm@test.com")
cls.pm_user.groups.add(cls.pm_group)

def test_url_and_templates(self):
def test_url_and_templates(self) -> None:
chant = make_fake_chant()
response = self.client.get(reverse("chant-detail", args=[chant.id]))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "base.html")
self.assertTemplateUsed(response, "chant_detail.html")

def test_context_folios(self):
def test_context_folios(self) -> None:
# create a source and several chants in it
source = make_fake_source()
chant = make_fake_chant(source=source, folio="001r")
Expand All @@ -62,7 +68,7 @@ def test_context_folios(self):
folios = response.context["folios"]
self.assertEqual(list(folios), ["001r", "001v", "002r", "002v"])

def test_context_previous_and_next_folio(self):
def test_context_previous_and_next_folio(self) -> None:
# create a source and several chants in it
source = make_fake_source()
# three folios: 001r, 001v, 002r
Expand Down Expand Up @@ -93,7 +99,7 @@ def test_context_previous_and_next_folio(self):
self.assertEqual(response.context["previous_folio"], "001v")
self.assertIsNone(response.context["next_folio"])

def test_published_vs_unpublished(self):
def test_published_vs_unpublished(self) -> None:
source = make_fake_source()
chant = make_fake_chant(source=source)

Expand All @@ -107,21 +113,11 @@ def test_published_vs_unpublished(self):
response = self.client.get(reverse("chant-detail", args=[chant.id]))
self.assertEqual(response.status_code, 403)

def test_chant_edit_link(self):
def test_chant_edit_link(self) -> None:
source = make_fake_source()
chant = make_fake_chant(
source=source,
folio="001r",
manuscript_full_text_std_spelling="manuscript_full_text_std_spelling",
)
chant = make_fake_chant(source=source, folio="001r")

# have to create project manager user - "View | Edit" toggle only visible for those with edit access for a chant's source
pm_user = get_user_model().objects.create(email="test@test.com")
pm_user.set_password("pass")
pm_user.save()
project_manager = Group.objects.get(name="project manager")
project_manager.user_set.add(pm_user)
self.client.login(email="test@test.com", password="pass")
self.client.force_login(self.pm_user)

response = self.client.get(reverse("chant-detail", args=[chant.id]))
expected_url_fragment = (
Expand All @@ -130,21 +126,23 @@ def test_chant_edit_link(self):

self.assertIn(expected_url_fragment, str(response.content))

def test_chant_with_volpiano_with_no_fulltext(self):
# in the past, a Chant Detail page will error rather than loading properly when the chant has volpiano but no fulltext
def test_chant_with_volpiano_with_no_fulltext(self) -> None:
# in the past, a Chant Detail page will error rather than loading
# properly when the chant has volpiano but no fulltext
source = make_fake_source()
chant = make_fake_chant(
source=source,
volpiano="1---c--g--e---e---d---c---c---f---e---e--d---d---c",
manuscript_full_text=None,
manuscript_full_text_std_spelling=None,
)
chant.manuscript_full_text = None
chant.manuscript_full_text_std_spelling = None
chant.save()

response = self.client.get(reverse("chant-detail", args=[chant.id]))
self.assertEqual(response.status_code, 200)

def test_chant_with_volpiano_with_no_incipit(self):
# in the past, a Chant Detail page will error rather than loading properly when the chant has volpiano but no fulltext/incipit
def test_chant_with_volpiano_with_no_incipit(self) -> None:
# in the past, a Chant Detail page will error rather than loading properly
# when the chant has volpiano but no fulltext/incipit
source = make_fake_source()
chant = make_fake_chant(
source=source,
Expand All @@ -157,6 +155,17 @@ def test_chant_with_volpiano_with_no_incipit(self):
response = self.client.get(reverse("chant-detail", args=[chant.id]))
self.assertEqual(response.status_code, 200)

def test_json_response(self) -> None:
chant = make_fake_chant()
response = self.client.get(
reverse("chant-detail", args=[chant.id]), HTTP_ACCEPT="application/json"
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
resp_chant = response.json()["chant"]
self.assertEqual(resp_chant["id"], chant.id)
self.assertEqual(resp_chant["manuscript_full_text"], chant.manuscript_full_text)


class SourceEditChantsViewTest(TestCase):
@classmethod
Expand Down
27 changes: 20 additions & 7 deletions django/cantusdb_project/main_app/tests/test_views/test_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group

from main_app.models import Source, Sequence, Chant, Differentia
from main_app.models import Source, Chant, Differentia
from main_app.tests.make_fakes import (
make_fake_source,
make_fake_segment,
Expand Down Expand Up @@ -123,14 +123,14 @@ def test_edit_source(self):


class SourceDetailViewTest(TestCase):
def test_url_and_templates(self):
def test_url_and_templates(self) -> None:
source = make_fake_source()
response = self.client.get(reverse("source-detail", args=[source.id]))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "base.html")
self.assertTemplateUsed(response, "source_detail.html")

def test_context_chant_folios(self):
def test_context_chant_folios(self) -> None:
# create a source and several chants in it
source = make_fake_source()
make_fake_chant(source=source, folio="001r")
Expand All @@ -145,7 +145,7 @@ def test_context_chant_folios(self):
folios = response.context["folios"]
self.assertEqual(list(folios), ["001r", "001v", "002r", "002v"])

def test_context_sequence_folios(self):
def test_context_sequence_folios(self) -> None:
# create a sequence source and several sequences in it
bower_segment = make_fake_segment(id=4064, name="Bower Sequence Database")
source = make_fake_source(
Expand All @@ -165,7 +165,7 @@ def test_context_sequence_folios(self):
# the folios should be ordered by the "folio" field
self.assertEqual(folios.query.order_by, ("folio",))

def test_context_sequences(self):
def test_context_sequences(self) -> None:
# create a sequence source and several sequences in it
source = make_fake_source(
segment=make_fake_segment(id=4064, name="Bower Sequence Database"),
Expand All @@ -180,7 +180,7 @@ def test_context_sequences(self):
# the list of sequences should be ordered by the "sequence" field
self.assertEqual(response.context["sequences"].query.order_by, ("s_sequence",))

def test_published_vs_unpublished(self):
def test_published_vs_unpublished(self) -> None:
source = make_fake_source(published=False)
response_1 = self.client.get(reverse("source-detail", args=[source.id]))
self.assertEqual(response_1.status_code, 403)
Expand All @@ -190,7 +190,7 @@ def test_published_vs_unpublished(self):
response_2 = self.client.get(reverse("source-detail", args=[source.id]))
self.assertEqual(response_2.status_code, 200)

def test_chant_list_link(self):
def test_chant_list_link(self) -> None:
cantus_segment = make_fake_segment(id=4063)
cantus_source = make_fake_source(segment=cantus_segment)
chant_list_link = reverse("browse-chants", args=[cantus_source.id])
Expand All @@ -210,6 +210,19 @@ def test_chant_list_link(self):
bower_source_html = str(bower_source_response.content)
self.assertNotIn(bower_chant_list_link, bower_source_html)

def test_json_response(self) -> None:
source = make_fake_source()
response = self.client.get(
reverse(
"source-detail",
args=[source.id],
),
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
self.assertEqual(response.json()["source"]["id"], source.id)


class SourceInventoryViewTest(HTMLContentsTestMixin, TestCase):
def test_url_and_templates(self):
Expand Down
24 changes: 23 additions & 1 deletion django/cantusdb_project/main_app/views/chant.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
user_can_proofread_chant,
user_can_view_chant,
)
from main_app.mixins import JSONResponseMixin
from users.models import User

CHANT_SEARCH_TEMPLATE_VALUES: tuple[str, ...] = (
Expand Down Expand Up @@ -278,14 +279,35 @@ def get_chants_with_folios(chants_in_feast: QuerySet) -> list:
return list(folios_chants.items())


class ChantDetailView(DetailView): # type: ignore[type-arg]
class ChantDetailView(JSONResponseMixin, DetailView): # type: ignore[type-arg]
"""
Displays a single Chant object. Accessed with ``chants/<int:pk>``
"""

model = Chant
context_object_name = "chant"
template_name = "chant_detail.html"
json_fields = [
"id",
"folio",
"c_sequence",
"cantus_id",
"feast__name",
"service__name",
"service__description",
"genre__name",
"genre__description",
"position",
"mode",
"differentia",
"differentiae_database",
"marginalia",
"finalis",
"manuscript_full_text",
"manuscript_full_text_std_spelling",
"volpiano",
"source_id",
]

def get_queryset(self) -> QuerySet[Chant]:
qs = super().get_queryset()
Expand Down
15 changes: 12 additions & 3 deletions django/cantusdb_project/main_app/views/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
get_feast_selector_options,
user_can_edit_chants_in_source,
)
from main_app.mixins import JSONResponseMixin

CANTUS_SEGMENT_ID = 4063
BOWER_SEGMENT_ID = 4064
Expand Down Expand Up @@ -205,17 +206,25 @@ def get_context_data(self, **kwargs):
return context


class SourceDetailView(DetailView):
class SourceDetailView(JSONResponseMixin, DetailView): # type: ignore[type-arg]
model = Source
context_object_name = "source"
template_name = "source_detail.html"
json_fields = [
"id",
"description",
"provenance__name",
"date",
"heading",
"short_heading",
]

def get_queryset(self):
def get_queryset(self) -> QuerySet[Source]:
return self.model.objects.select_related(
"holding_institution", "segment", "provenance", "created_by"
).all()

def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
source = self.object
user = self.request.user

Expand Down

0 comments on commit 0938ad1

Please sign in to comment.