From 012d63ab3d5c36027e6bdb5e02e174b4e229fa43 Mon Sep 17 00:00:00 2001 From: Dylan Hillerbrand Date: Tue, 17 Dec 2024 14:51:11 -0500 Subject: [PATCH 1/3] feat(JSONResponseMixin): support double-underscore field lookups --- django/cantusdb_project/main_app/mixins.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/django/cantusdb_project/main_app/mixins.py b/django/cantusdb_project/main_app/mixins.py index a8196905e..bf163a19d 100644 --- a/django/cantusdb_project/main_app/mixins.py +++ b/django/cantusdb_project/main_app/mixins.py @@ -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: @@ -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()) From d011a8132b3162cf80bb1e2dabd64668a114124d Mon Sep 17 00:00:00 2001 From: Dylan Hillerbrand Date: Tue, 17 Dec 2024 14:51:40 -0500 Subject: [PATCH 2/3] feat(source/chant detail): add JSONResponseMixin --- .../cantusdb_project/main_app/permissions.py | 8 +-- .../main_app/tests/test_views/test_chant.py | 61 +++++++++++-------- .../main_app/tests/test_views/test_source.py | 27 +++++--- .../cantusdb_project/main_app/views/chant.py | 22 ++++++- .../cantusdb_project/main_app/views/source.py | 15 ++++- 5 files changed, 92 insertions(+), 41 deletions(-) diff --git a/django/cantusdb_project/main_app/permissions.py b/django/cantusdb_project/main_app/permissions.py index 4bbd65d82..8f1bc8bf3 100644 --- a/django/cantusdb_project/main_app/permissions.py +++ b/django/cantusdb_project/main_app/permissions.py @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/django/cantusdb_project/main_app/tests/test_views/test_chant.py b/django/cantusdb_project/main_app/tests/test_views/test_chant.py index e7f2ebecb..965536e37 100644 --- a/django/cantusdb_project/main_app/tests/test_views/test_chant.py +++ b/django/cantusdb_project/main_app/tests/test_views/test_chant.py @@ -29,6 +29,7 @@ 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 @@ -36,18 +37,23 @@ 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") @@ -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 @@ -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) @@ -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 = ( @@ -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, @@ -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 diff --git a/django/cantusdb_project/main_app/tests/test_views/test_source.py b/django/cantusdb_project/main_app/tests/test_views/test_source.py index 011d87af8..ecab20445 100644 --- a/django/cantusdb_project/main_app/tests/test_views/test_source.py +++ b/django/cantusdb_project/main_app/tests/test_views/test_source.py @@ -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, @@ -122,14 +122,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") @@ -144,7 +144,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( @@ -164,7 +164,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"), @@ -179,7 +179,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) @@ -189,7 +189,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]) @@ -209,6 +209,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(TestCase): def test_url_and_templates(self): diff --git a/django/cantusdb_project/main_app/views/chant.py b/django/cantusdb_project/main_app/views/chant.py index 4d7fb3de8..4b5c000ce 100644 --- a/django/cantusdb_project/main_app/views/chant.py +++ b/django/cantusdb_project/main_app/views/chant.py @@ -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, ...] = ( @@ -278,7 +279,7 @@ 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/`` """ @@ -286,6 +287,25 @@ class ChantDetailView(DetailView): # type: ignore[type-arg] model = Chant context_object_name = "chant" template_name = "chant_detail.html" + json_fields = [ + "id", + "folio", + "c_sequence", + "cantus_id", + "feast_id", + "service_id", + "genre_id", + "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() diff --git a/django/cantusdb_project/main_app/views/source.py b/django/cantusdb_project/main_app/views/source.py index 4414f9468..29df61a6e 100644 --- a/django/cantusdb_project/main_app/views/source.py +++ b/django/cantusdb_project/main_app/views/source.py @@ -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 @@ -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 From ab8096a0f6647085c61860dac6edcc5b22a9e360 Mon Sep 17 00:00:00 2001 From: Dylan Hillerbrand Date: Tue, 17 Dec 2024 15:00:19 -0500 Subject: [PATCH 3/3] fix(chant detail): add service/genre names and desc to json response --- django/cantusdb_project/main_app/views/chant.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/django/cantusdb_project/main_app/views/chant.py b/django/cantusdb_project/main_app/views/chant.py index 4b5c000ce..bee85bc1d 100644 --- a/django/cantusdb_project/main_app/views/chant.py +++ b/django/cantusdb_project/main_app/views/chant.py @@ -292,9 +292,11 @@ class ChantDetailView(JSONResponseMixin, DetailView): # type: ignore[type-arg] "folio", "c_sequence", "cantus_id", - "feast_id", - "service_id", - "genre_id", + "feast__name", + "service__name", + "service__description", + "genre__name", + "genre__description", "position", "mode", "differentia",