diff --git a/django/cantusdb_project/main_app/forms.py b/django/cantusdb_project/main_app/forms.py index 5062361d7..b75f15ec3 100644 --- a/django/cantusdb_project/main_app/forms.py +++ b/django/cantusdb_project/main_app/forms.py @@ -249,14 +249,18 @@ def clean(self) -> dict[str, Any]: # Call super().clean() to ensure that the form's built-in validation # is run before our custom validation. super().clean() - folio = self.cleaned_data["folio"] - c_sequence = self.cleaned_data["c_sequence"] - source = self.cleaned_data["source"] - if source.chant_set.filter(folio=folio, c_sequence=c_sequence): - raise forms.ValidationError( - "Chant with the same sequence and folio already exists in this source.", - code="duplicate-folio-sequence", - ) + # Only do this additional validation if the form is otherwise valid. + # For example, if the `folio` field has been left blank, then we can't + # use is to check for uniqueness. + if self.is_valid(): + folio = self.cleaned_data["folio"] + c_sequence = self.cleaned_data["c_sequence"] + source = self.cleaned_data["source"] + if source.chant_set.filter(folio=folio, c_sequence=c_sequence): + raise forms.ValidationError( + "Chant with the same sequence and folio already exists in this source.", + code="duplicate-folio-sequence", + ) return self.cleaned_data @@ -953,7 +957,7 @@ class Meta: ) -class ImageLinkForm(forms.Form): +class AddImageLinksForm(forms.Form): """ Subclass of Django's Form class that creates the form we use for adding image links to chants in a source. @@ -985,6 +989,22 @@ def save(self, source: Source) -> None: if image_link != "": source.chant_set.filter(folio=folio).update(image_link=image_link) + +ChantCreateFormset = forms.inlineformset_factory( + Source, + Chant, + form=ChantCreateForm, + extra=0, + max_num=250, + can_delete=False, + can_order=False, +) + + +class ChantCreateFromCSVForm(forms.Form): + new_chants = forms.JSONField(widget=forms.HiddenInput) + + class BrowseChantsBulkEditForm(forms.ModelForm): class Meta: model = Chant diff --git a/django/cantusdb_project/main_app/mixins.py b/django/cantusdb_project/main_app/mixins.py index bf163a19d..831f75e07 100644 --- a/django/cantusdb_project/main_app/mixins.py +++ b/django/cantusdb_project/main_app/mixins.py @@ -64,7 +64,7 @@ def render_to_response( for field in json_fields: 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 = self.object_list.values(*json_fields) # type: ignore[attr-defined] q_s_name = str(q_s.model.get_verbose_name_plural()) return JsonResponse({q_s_name: list(q_s)}) try: diff --git a/django/cantusdb_project/main_app/templates/source_bulk_actions/add_chants.html b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_chants.html new file mode 100644 index 000000000..9039a6d19 --- /dev/null +++ b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_chants.html @@ -0,0 +1,107 @@ +{% extends "base.html" %} +{% load static %} +{% block title %} + Bulk Add Chants to Source: {{ source.short_heading }} +{% endblock %} +{% block scripts %} + + +{% endblock %} +{% block content %} +
+
+
+

+ Bulk Add Chants to Source: {{ source.heading }} +

+

+ Use this form to add chants from a csv file to this source. Each row of the csv file should + contain the data for a single chant (note: this form is limited to creating 250 chants additional + a time). The following columns are required: +

    +
  • folio
  • +
  • sequence
  • +
  • full_text_std_spelling
  • +
+

+

+ The following optional columns are also supported: +

    +
  • rubrics
  • +
  • indexing_notes
  • +
  • marginalia
  • +
  • content_structure
  • +
  • feast
  • +
  • service
  • +
  • genre
  • +
  • position
  • +
  • liturgical_function
  • +
  • cantus_id
  • +
  • mode
  • +
  • finalis
  • +
  • differentia
  • +
  • full_text_source_spelling
  • +
  • extra
  • +
  • chant_range
  • +
  • addendum
  • +
  • polyphony
  • +
+

+

+ The following checks will be performed on the csv file before it is saved. Failure of any of these + checks will leave all chants unsaved and an error message will be displayed. +

+
    +
  1. A check that the required columns are present.
  2. +
  3. A check that any additional columns correspond to valid chant data fields.
  4. +
  5. + A check that the source does not contain any chants with the same folio and sequence. +
  6. +
  7. + A check that the values of any of the following fields correspond to valid values: +
      +
    • feast
    • +
    • genre
    • +
    • service
    • +
    • polyphony
    • +
    • liturgical_function
    • +
    +
  8. +
+
+
+ +
+
+ +
+ {% csrf_token %} + {{ form }} +
+
+
+ +
+
+
+ +
+{% endblock %} diff --git a/django/cantusdb_project/main_app/templates/source_add_image_links.html b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_image_links.html similarity index 98% rename from django/cantusdb_project/main_app/templates/source_add_image_links.html rename to django/cantusdb_project/main_app/templates/source_bulk_actions/add_image_links.html index d7a4c0901..b6822fd6e 100644 --- a/django/cantusdb_project/main_app/templates/source_add_image_links.html +++ b/django/cantusdb_project/main_app/templates/source_bulk_actions/add_image_links.html @@ -5,7 +5,7 @@ {% endblock %} {% block scripts %} - + {% endblock %} {% block content %}
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 cd23b816a..e1c8785d6 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 @@ -1358,114 +1358,3 @@ def test_ordering(self) -> None: self.assertEqual( list(reversed(expected_source_order)), list(response_sources_reverse) ) - - -class SourceAddImageLinksViewTest(TestCase): - auth_user: User - non_auth_user: User - source: Source - - @classmethod - def setUpTestData(cls) -> None: - user_model = get_user_model() - cls.auth_user = user_model.objects.create( - email="authuser@test.com", password="12345", is_staff=True - ) - cls.non_auth_user = user_model.objects.create( - email="nonauthuser@test.com", password="12345", is_staff=False - ) - cls.source = make_fake_source(published=True) - for folio in ["001r", "001v", "003", "004A"]: - make_fake_chant(source=cls.source, folio=folio, image_link=None) - # Make a second chant for one of the folios, with an existing image link. - # We'll update this image_link in the process. - make_fake_chant( - source=cls.source, folio="001v", image_link="https://i-already-exist.com" - ) - # Make a final chant for a different folio with an existing image link. We - # won't update this image_link in the process. - make_fake_chant( - source=cls.source, folio="004B", image_link="https://i-already-exist.com/2" - ) - - def test_permissions(self) -> None: - with self.subTest("Test unauthenticated user"): - response = self.client.get( - reverse("source-add-image-links", args=[self.source.id]) - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects( - response, - f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}", - status_code=302, - target_status_code=200, - ) - response = self.client.post( - reverse("source-add-image-links", args=[self.source.id]) - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects( - response, - f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}", - status_code=302, - target_status_code=200, - ) - with self.subTest("Test non-staff user"): - self.client.force_login(self.non_auth_user) - response = self.client.get( - reverse("source-add-image-links", args=[self.source.id]) - ) - self.assertEqual(response.status_code, 403) - response = self.client.post( - reverse("source-add-image-links", args=[self.source.id]) - ) - self.assertEqual(response.status_code, 403) - with self.subTest("Test staff user"): - self.client.force_login(self.auth_user) - response = self.client.get( - reverse("source-add-image-links", args=[self.source.id]) - ) - self.assertEqual(response.status_code, 200) - # Post redirect is tested in the `test_form` method - - def test_form(self) -> None: - with self.subTest("Test form fields"): - self.client.force_login(self.auth_user) - response = self.client.get( - reverse("source-add-image-links", args=[self.source.id]) - ) - self.assertEqual(response.status_code, 200) - self.assertTemplateUsed(response, "source_add_image_links.html") - form = response.context["form"] - self.assertListEqual( - list(form.fields.keys()), ["001r", "001v", "003", "004A", "004B"] - ) - with self.subTest("Test form submission"): - response = self.client.post( - reverse("source-add-image-links", args=[self.source.id]), - { - "001r": "https://example.com/001r", - "001v": "https://example.com/001v", - "004A": "https://example.com/004A", - }, - ) - self.assertRedirects( - response, - reverse("source-detail", args=[self.source.id]), - status_code=302, - target_status_code=200, - ) - with self.subTest("Test saved data"): - chants_001r = Chant.objects.filter(source=self.source, folio="001r").all() - self.assertEqual(len(chants_001r), 1) - self.assertEqual(chants_001r[0].image_link, "https://example.com/001r") - chants_001v = Chant.objects.filter(source=self.source, folio="001v").all() - self.assertEqual(len(chants_001v), 2) - for chant in chants_001v: - self.assertEqual(chant.image_link, "https://example.com/001v") - chants_003 = Chant.objects.filter(source=self.source, folio="003").all() - self.assertEqual(len(chants_003), 1) - self.assertIsNone(chants_003[0].image_link) - chants_004B = Chant.objects.filter(source=self.source, folio="004B").all() - self.assertEqual(len(chants_004B), 1) - self.assertEqual(chants_004B[0].image_link, "https://i-already-exist.com/2") diff --git a/django/cantusdb_project/main_app/tests/test_views/test_source_bulk_actions.py b/django/cantusdb_project/main_app/tests/test_views/test_source_bulk_actions.py new file mode 100644 index 000000000..21ec06709 --- /dev/null +++ b/django/cantusdb_project/main_app/tests/test_views/test_source_bulk_actions.py @@ -0,0 +1,289 @@ +import ujson + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse + +from main_app.models import Source, Chant +from main_app.tests.make_fakes import ( + make_fake_source, + make_fake_chant, + make_fake_segment, +) +from users.models import User + + +class AddImageLinksViewTest(TestCase): + auth_user: User + non_auth_user: User + source: Source + + @classmethod + def setUpTestData(cls) -> None: + user_model = get_user_model() + cls.auth_user = user_model.objects.create( + email="authuser@test.com", password="12345", is_staff=True + ) + cls.non_auth_user = user_model.objects.create( + email="nonauthuser@test.com", password="12345", is_staff=False + ) + cls.source = make_fake_source(published=True) + for folio in ["001r", "001v", "003", "004A"]: + make_fake_chant(source=cls.source, folio=folio, image_link=None) + # Make a second chant for one of the folios, with an existing image link. + # We'll update this image_link in the process. + make_fake_chant( + source=cls.source, folio="001v", image_link="https://i-already-exist.com" + ) + # Make a final chant for a different folio with an existing image link. We + # won't update this image_link in the process. + make_fake_chant( + source=cls.source, folio="004B", image_link="https://i-already-exist.com/2" + ) + + def test_permissions(self) -> None: + with self.subTest("Test unauthenticated user"): + response = self.client.get( + reverse("source-add-image-links", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 302) + self.assertRedirects( + response, + f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}", + status_code=302, + target_status_code=200, + ) + response = self.client.post( + reverse("source-add-image-links", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 302) + self.assertRedirects( + response, + f"{reverse('login')}?next={reverse('source-add-image-links', args=[self.source.id])}", + status_code=302, + target_status_code=200, + ) + with self.subTest("Test non-staff user"): + self.client.force_login(self.non_auth_user) + response = self.client.get( + reverse("source-add-image-links", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 403) + response = self.client.post( + reverse("source-add-image-links", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 403) + with self.subTest("Test staff user"): + self.client.force_login(self.auth_user) + response = self.client.get( + reverse("source-add-image-links", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 200) + # Post redirect is tested in the `test_form` method + + def test_form(self) -> None: + with self.subTest("Test form fields"): + self.client.force_login(self.auth_user) + response = self.client.get( + reverse("source-add-image-links", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed( + response, "source_bulk_actions/add_image_links.html" + ) + form = response.context["form"] + self.assertListEqual( + list(form.fields.keys()), ["001r", "001v", "003", "004A", "004B"] + ) + with self.subTest("Test form submission"): + response = self.client.post( + reverse("source-add-image-links", args=[self.source.id]), + { + "001r": "https://example.com/001r", + "001v": "https://example.com/001v", + "004A": "https://example.com/004A", + }, + ) + self.assertRedirects( + response, + reverse("source-detail", args=[self.source.id]), + status_code=302, + target_status_code=200, + ) + with self.subTest("Test saved data"): + chants_001r = Chant.objects.filter(source=self.source, folio="001r").all() + self.assertEqual(len(chants_001r), 1) + self.assertEqual(chants_001r[0].image_link, "https://example.com/001r") + chants_001v = Chant.objects.filter(source=self.source, folio="001v").all() + self.assertEqual(len(chants_001v), 2) + for chant in chants_001v: + self.assertEqual(chant.image_link, "https://example.com/001v") + chants_003 = Chant.objects.filter(source=self.source, folio="003").all() + self.assertEqual(len(chants_003), 1) + self.assertIsNone(chants_003[0].image_link) + chants_004B = Chant.objects.filter(source=self.source, folio="004B").all() + self.assertEqual(len(chants_004B), 1) + self.assertEqual(chants_004B[0].image_link, "https://i-already-exist.com/2") + + +class AddChantsViewTest(TestCase): + auth_user: User + non_auth_user: User + source: Source + + @classmethod + def setUpTestData(cls) -> None: + segment = make_fake_segment(id=4063) + cls.source = make_fake_source(published=True, segment=segment) + user_model = get_user_model() + cls.auth_user = user_model.objects.create( + email="authuser@test.com", password="12345", is_staff=True + ) + cls.non_auth_user = user_model.objects.create( + email="nonauthuser@test.com", password="12345", is_staff=False + ) + + def test_permissions(self) -> None: + with self.subTest("Test unauthenticated user"): + response = self.client.get( + reverse("source-add-chants", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 302) + self.assertRedirects( + response, + f"{reverse('login')}?next={reverse('source-add-chants', args=[self.source.id])}", + status_code=302, + target_status_code=200, + ) + response = self.client.post( + reverse("source-add-chants", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 302) + self.assertRedirects( + response, + f"{reverse('login')}?next={reverse('source-add-chants', args=[self.source.id])}", + status_code=302, + target_status_code=200, + ) + with self.subTest("Test non-staff user"): + self.client.force_login(self.non_auth_user) + response = self.client.get( + reverse("source-add-chants", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 403) + response = self.client.post( + reverse("source-add-chants", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 403) + with self.subTest("Test staff user"): + self.client.force_login(self.auth_user) + response = self.client.get( + reverse("source-add-chants", args=[self.source.id]) + ) + self.assertEqual(response.status_code, 200) + # Post redirect is tested in the `test_post` method + + def test_post(self) -> None: + """ + Tests the following possibilities for the POST request: + - the data sent with the POST request does not pass + ChantCreateFromCSVForm validation + - the data sent with the POST request passes ChantCreateFromCSVForm + validation, but not the ChantCreateFormset validation + - the data sent with the POST request passes ChantCreateFromCSVForm + validation and the ChantCreateFormset validation + """ + self.client.force_login(self.auth_user) + with self.subTest("Invalid ChantCreateFromCSVForm"): + response = self.client.post( + reverse("source-add-chants", args=[self.source.id]), + { + "new_chants": "Some text that is not JSON.", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual( + response.content, + { + "form_error": "The submitted form is invalid. Please try again.", + }, + ) + with self.subTest("Valid ChantCreateFromCSVForm; invalid chants"): + make_fake_chant(source=self.source, folio="001r", c_sequence=1) + invalid_new_chants = [ + { + "folio": "001r", # This chant repeats the folio + "c_sequence": 1, # and c_sequence of an existing chant + "full_text_std_spelling": "Some standard full text", + }, + { + "folio": "001v", + "c_sequence": 1, + # This chant contains no full_text_std_spelling + "polyphony": "Some polyphony", # Not a valid choice for this field + }, + { # a valid chant + "folio": "001v", + "c_sequence": 2, + "full_text_std_spelling": "Some other standard full text", + }, + ] + response = self.client.post( + reverse("source-add-chants", args=[self.source.id]), + { + "new_chants": ujson.dumps(invalid_new_chants), + }, + ) + self.assertEqual(response.status_code, 400) + formset_errors = response.json()["formset_errors"] + expected_formset_errors = [ + { + "form_idx": 0, + "field_name": "__all__", + "error": [ + "Chant with the same sequence and folio already exists in this source." + ], + }, + { + "form_idx": 1, + "field_name": "full_text_std_spelling", + "error": ["This field is required."], + }, + { + "form_idx": 1, + "field_name": "polyphony", + "error": [ + "Select a valid choice. Some polyphony is not one of the available choices." + ], + }, + ] + self.assertEqual(formset_errors, expected_formset_errors) + # Test no additional chants were saved + self.assertEqual(Chant.objects.filter(source=self.source).count(), 1) + with self.subTest("Valid ChantCreateFromCSVForm; valid chants"): + valid_new_chants = [ + { + "folio": "001v", + "c_sequence": 1, + "full_text_std_spelling": "Some standard full text", + }, + { + "folio": "001v", + "c_sequence": 2, + "full_text_std_spelling": "Some other standard full text", + }, + ] + response = self.client.post( + reverse("source-add-chants", args=[self.source.id]), + { + "new_chants": ujson.dumps(valid_new_chants), + }, + ) + self.assertRedirects( + response, + reverse("browse-chants", args=[self.source.id]), + status_code=302, + target_status_code=200, + ) + chants = Chant.objects.filter(source=self.source).all() + self.assertEqual(len(chants), 3) diff --git a/django/cantusdb_project/main_app/urls.py b/django/cantusdb_project/main_app/urls.py index 307380857..4bb3281aa 100644 --- a/django/cantusdb_project/main_app/urls.py +++ b/django/cantusdb_project/main_app/urls.py @@ -85,7 +85,6 @@ SourceListView, SourceDeleteView, SourceInventoryView, - SourceAddImageLinksView, ) from main_app.views.user import ( CustomLogoutView, @@ -106,6 +105,10 @@ ProofreadByAutocomplete, HoldingAutocomplete, ) +from main_app.views.source_bulk_actions import ( + AddImageLinksView, + AddChantsView, +) from main_app.views.auth import change_password urlpatterns = [ @@ -367,9 +370,14 @@ ), path( "source//add-image-links", - SourceAddImageLinksView.as_view(), + AddImageLinksView.as_view(), name="source-add-image-links", ), + path( + "source//add-chants", + AddChantsView.as_view(), + name="source-add-chants", + ), # melody path( "melody/", diff --git a/django/cantusdb_project/main_app/views/autocomplete.py b/django/cantusdb_project/main_app/views/autocomplete.py index 6e8fa446c..a6d7b9a24 100644 --- a/django/cantusdb_project/main_app/views/autocomplete.py +++ b/django/cantusdb_project/main_app/views/autocomplete.py @@ -1,5 +1,5 @@ from dal import autocomplete -from django.db.models import Q +from django.db.models import Q, QuerySet from django.contrib.auth import get_user_model from main_app.models import ( @@ -57,9 +57,7 @@ def get_queryset(self): class FeastAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - if not self.request.user.is_authenticated: - return Feast.objects.none() + def get_queryset(self) -> QuerySet[Feast]: qs = Feast.objects.all().order_by("name") if self.q: qs = qs.filter(name__icontains=self.q) @@ -67,10 +65,10 @@ def get_queryset(self): class ServiceAutocomplete(autocomplete.Select2QuerySetView): - def get_result_label(self, result): + def get_result_label(self, result: Service) -> str: return f"{result.name} - {result.description}" - def get_queryset(self): + def get_queryset(self) -> QuerySet[Service]: if not self.request.user.is_authenticated: return Service.objects.none() qs = Service.objects.all().order_by("name") @@ -82,12 +80,10 @@ def get_queryset(self): class GenreAutocomplete(autocomplete.Select2QuerySetView): - def get_result_label(self, result): + def get_result_label(self, result: Genre) -> str: return f"{result.name} - {result.description}" - def get_queryset(self): - if not self.request.user.is_authenticated: - return Genre.objects.none() + def get_queryset(self) -> QuerySet[Genre]: qs = Genre.objects.all().order_by("name") if self.q: qs = qs.filter( diff --git a/django/cantusdb_project/main_app/views/feast.py b/django/cantusdb_project/main_app/views/feast.py index 187b3695e..14ddd5ae4 100644 --- a/django/cantusdb_project/main_app/views/feast.py +++ b/django/cantusdb_project/main_app/views/feast.py @@ -7,6 +7,7 @@ from extra_views import SearchableListMixin from main_app.models import Feast +from main_app.mixins import JSONResponseMixin # this categorization is not finalized yet # the feastcode on old cantus requires cleaning @@ -86,10 +87,11 @@ def namedtuple_fetch(results, description) -> Generator[NamedTuple, None, None]: yield nt_result(*res) -class FeastDetailView(DetailView): +class FeastDetailView(JSONResponseMixin, DetailView): model = Feast context_object_name = "feast" template_name = "feast_detail.html" + json_fields = ["id", "name", "description", "feast_code"] def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -129,7 +131,7 @@ def get_context_data(self, **kwargs): return context -class FeastListView(SearchableListMixin, ListView): +class FeastListView(JSONResponseMixin, SearchableListMixin, ListView): """Searchable List view for Feast model Accessed by /feasts/ @@ -146,6 +148,7 @@ class FeastListView(SearchableListMixin, ListView): paginate_by = 100 context_object_name = "feasts" template_name = "feast_list.html" + json_fields = ["id", "name", "description", "feast_code"] def get_ordering(self) -> tuple: ordering = self.request.GET.get("sort_by") diff --git a/django/cantusdb_project/main_app/views/source.py b/django/cantusdb_project/main_app/views/source.py index be2bcc26e..d71941a21 100644 --- a/django/cantusdb_project/main_app/views/source.py +++ b/django/cantusdb_project/main_app/views/source.py @@ -1,5 +1,5 @@ import re -from typing import Any, Optional +from typing import Any from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin @@ -17,14 +17,11 @@ UpdateView, DeleteView, TemplateView, - FormView, ) -from django.views.generic.detail import SingleObjectMixin from main_app.forms import ( SourceCreateForm, SourceEditForm, SourceBrowseChantsProofreadForm, - ImageLinkForm, BrowseChantsBulkEditFormset, ) from main_app.models import ( @@ -622,46 +619,3 @@ def get_context_data(self, **kwargs): context["chants"] = queryset return context - - -class SourceAddImageLinksView(UserPassesTestMixin, SingleObjectMixin, FormView): # type: ignore - template_name = "source_add_image_links.html" - pk_url_kwarg = "source_id" - queryset = Source.objects.select_related("holding_institution") - context_object_name = "source" - form_class = ImageLinkForm - object: Source - http_method_names = ["get", "post"] - - def test_func(self) -> bool: - return user_can_manage_source_editors(self.request.user) - - def get_success_url(self) -> str: - return reverse("source-detail", args=[self.object.id]) - - def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - self.object = self.get_object() - return super().get(request, *args, **kwargs) - - def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - self.object = self.get_object() - return super().post(request, *args, **kwargs) - - def get_initial(self) -> dict[str, Any]: - """ - Set the initial data required by the ImageLinkForm - on GET requests. - """ - folios: QuerySet[Chant, Optional[str]] = ( - self.object.chant_set.values_list("folio", flat=True) - .distinct() - .order_by("folio") - ) - return {folio: "" for folio in folios if folio} - - def form_valid(self, form: ImageLinkForm) -> HttpResponseRedirect: - """ - Save the image links to the database. - """ - form.save(self.object) - return HttpResponseRedirect(self.get_success_url()) diff --git a/django/cantusdb_project/main_app/views/source_bulk_actions.py b/django/cantusdb_project/main_app/views/source_bulk_actions.py new file mode 100644 index 000000000..e145f4686 --- /dev/null +++ b/django/cantusdb_project/main_app/views/source_bulk_actions.py @@ -0,0 +1,178 @@ +""" +This module contains views that are used to make bulk additions or edits to chants +(or some subset of chants) associated with a particular source. +""" + +from typing import Any, Optional +from django.http import ( + HttpRequest, + HttpResponse, + HttpResponseRedirect, + JsonResponse, +) +from django.views.generic.edit import FormView +from django.views.generic.detail import SingleObjectMixin +from django.contrib import messages +from django.contrib.auth.mixins import UserPassesTestMixin +from django.urls import reverse +from django.db.models.query import QuerySet +from django.forms.models import BaseInlineFormSet + +from main_app.models import Source, Chant +from main_app.forms import AddImageLinksForm, ChantCreateFormset, ChantCreateFromCSVForm +from main_app.permissions import user_can_manage_source_editors + + +class AddImageLinksView(UserPassesTestMixin, SingleObjectMixin, FormView): # type: ignore + template_name = "source_bulk_actions/add_image_links.html" + pk_url_kwarg = "source_id" + queryset = Source.objects.select_related("holding_institution") + context_object_name = "source" + form_class = AddImageLinksForm + object: Source + http_method_names = ["get", "post"] + + def test_func(self) -> bool: + return user_can_manage_source_editors(self.request.user) + + def get_success_url(self) -> str: + return reverse("source-detail", args=[self.object.id]) + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + self.object = self.get_object() + return super().post(request, *args, **kwargs) + + def get_initial(self) -> dict[str, Any]: + """ + Set the initial data required by the ImageLinkForm + on GET requests. + """ + folios: QuerySet[Chant, Optional[str]] = ( + self.object.chant_set.values_list("folio", flat=True) + .distinct() + .order_by("folio") + ) + return {folio: "" for folio in folios if folio} + + def form_valid(self, form: AddImageLinksForm) -> HttpResponseRedirect: + """ + Save the image links to the database. + """ + form.save(self.object) + return HttpResponseRedirect(self.get_success_url()) + + +class AddChantsView(UserPassesTestMixin, SingleObjectMixin, FormView): # type: ignore + template_name = "source_bulk_actions/add_chants.html" + http_method_names = ["get", "post"] + pk_url_kwarg = "source_id" + queryset = Source.objects.select_related("holding_institution") + context_object_name = "source" + object: Source + form_class = ChantCreateFromCSVForm + # The field_name map is used to map field names in the CSV file to + # field names on the Chant model. + field_name_map = { + "sequence": "c_sequence", + "full_text_std_spelling": "manuscript_full_text_std_spelling", + "full_text_source_spelling": "manuscript_full_text", + } + + def get_success_url(self) -> str: + return reverse("browse-chants", args=[self.object.id]) + + def test_func(self) -> bool: + return user_can_manage_source_editors(self.request.user) + + def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """ + The default `FormView` get method does not call the `SingleObjectMixin` + `get_object` method, so we need to call it manually. + """ + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """ + The default `FormView` post method does not call the `SingleObjectMixin` + `get_object` method, so we need to call it manually. + """ + self.object = self.get_object() + return super().post(request, *args, **kwargs) + + def form_valid(self, form: ChantCreateFromCSVForm) -> HttpResponse: + """ + If the form is valid, we need to save the new chants to the database. + For this, we create a formset with the data from the ChantCreateCSVForm's + cleaned and validated data. + + We use the ChantCreateFromCSVForm (which puts all new chant data in a single + JSON Field) since otherwise we would quickly run into an issue with having + too many fields (governed by the DATA_UPLOAD_MAX_NUMBER_FILES setting, because + of how the form data is encoded client-side) in the form. Rather than fine-tune + that setting to account for the varying number of chants that could be added and + dealing with formset management on the front-end, we use this single-field form. + """ + chant_data = form.cleaned_data["new_chants"] + # Add the management form data to the formset data + formset_data = { + "chant_set-TOTAL_FORMS": len(chant_data), + "chant_set-INITIAL_FORMS": 0, + } + form_count = 0 + for chant in chant_data: + for key, value in chant.items(): + mapped_field_name = self.field_name_map.get(key, key) + formset_data[f"chant_set-{form_count}-{mapped_field_name}"] = value + form_count += 1 + new_chant_formset = ChantCreateFormset(formset_data, instance=self.object) + if new_chant_formset.is_valid(): + new_chant_formset.save() + messages.success(self.request, f"{form_count} chants added successfully.") + return HttpResponseRedirect(self.get_success_url()) + return self.chant_formset_invalid(new_chant_formset) + + def form_invalid(self, form: ChantCreateFromCSVForm) -> HttpResponse: + """ + If the form is invalid, we'll pass back a 400 response. + """ + return JsonResponse( + status=400, + data={"form_error": "The submitted form is invalid. Please try again."}, + ) + + def chant_formset_invalid( + self, formset: BaseInlineFormSet # type: ignore[type-arg] + ) -> HttpResponse: + """ + If the formset is invalid, we'll pass back errors in the response + to be displayed to the user. + """ + # Errors will reference the model field names, so we need to map them + # back to the field names in the CSV file for display. + errors_list = [] + swapped_field_name_map = {v: k for k, v in self.field_name_map.items()} + for form_idx, form_errors in enumerate(formset.errors): + if form_errors: + for ( + field_name, + error, + ) in form_errors.items(): # type:ignore[attr-defined] + mapped_field_name = swapped_field_name_map.get( + field_name, field_name + ) + errors_list.append( + { + "form_idx": form_idx, + "field_name": mapped_field_name, + "error": error, + } + ) + return JsonResponse( + status=400, + data={"formset_errors": errors_list}, + ) diff --git a/django/cantusdb_project/static/css/source_add_image_links.css b/django/cantusdb_project/static/css/source_bulk_add.css similarity index 100% rename from django/cantusdb_project/static/css/source_add_image_links.css rename to django/cantusdb_project/static/css/source_bulk_add.css diff --git a/django/cantusdb_project/static/js/source_add_chants.js b/django/cantusdb_project/static/js/source_add_chants.js new file mode 100644 index 000000000..9ab332bdb --- /dev/null +++ b/django/cantusdb_project/static/js/source_add_chants.js @@ -0,0 +1,220 @@ +function updatePreviewHeader(columnHeaders) { + // Update the header row of the preview table + // - columnHeaders: an array of strings with the column headers + const tableHead = document.getElementById('csvPreviewHead'); + tableHead.innerHTML = ''; + for (let i = 0; i < columnHeaders.length; i++) { + const th = document.createElement('th'); + th.textContent = columnHeaders[i]; + tableHead.appendChild(th); + } +}; + +function createTableCell(value) { + // Create a table cell with the given value + // doing the necessary replacements for values + // coming from our csv file match regex: + // - unescape double quotes + // - remove leading and trailing quotes + // - remove leading and trailing commas + // - remove leading and trailing whitespace + value = value.replaceAll(/^[",]*|[,"]*$/g, ''); + value = value.trim().replaceAll(/""/g, '"'); + const td = document.createElement('td'); + td.textContent = value; + return [td, value]; +} + +function styleTableError(elem, error_message) { + // Style a table element (tr or td) as an error + elem.classList.add('table-danger'); + elem.setAttribute('data-bs-title', error_message); + new bootstrap.Tooltip(elem); +} + +function csvLoadCallback(csv, relatedFieldMaps) { + // Instantiate a FormData object with our existing + // form data. + const addChantsForm = new FormData(document.getElementById('addChantsForm')); + // Remove the values from a previously-selected file + // from the form data + addChantsForm.set('new_chants', null); + // Parse the CSV file and display it in the table. + parseCSVUpdateFormAndPreview(csv, addChantsForm, relatedFieldMaps); + return addChantsForm; +}; + + +async function getRelatedFieldMap(relatedField) { + // Get a map of name -> id for related field + // values. Calls the endpoint provided and returns + // a promise that resolves to the + // map of related field values. + // Intended to work with one of the following + // values for relatedField: + // - 'genres' + // - 'services' + // - 'feasts' + const endpoint = `/${relatedField}/`; + return fetch(endpoint, { headers: { 'Accept': 'application/json' } }) + .then(response => response.json()) + .then(data => { + const fieldData = data[relatedField]; + const nameIDMap = {}; + for (let i = 0; i < fieldData.length; i++) { + nameIDMap[fieldData[i].name] = fieldData[i].id; + } + return nameIDMap; + }) + .catch((error) => { + console.error('Error:', error); + }); +} + +function parseCSVUpdateFormAndPreview(csv, formData, relatedFieldMaps) { + // Parse the passed CSV file and update the preview + // table and the form with the new data. + const rows = csv.split('\n'); + const columnHeaders = rows[0].trim().split(','); + updatePreviewHeader(columnHeaders); + const tableBody = document.getElementById('csvPreviewBody'); + tableBody.innerHTML = ''; + newChantsJSON = []; + const relatedFieldNames = ['genre', 'service', 'feast']; + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + // Split the row into columns with a regex pattern that + // accounts for commas inside quotes. + const rowValues = row.match(/(?:"([^"]*(?:""[^"]*)*)")|([^",]+)|(?:,)()(?=,)/g) + const tr = document.createElement('tr'); + newChantObj = {}; + for (let j = 0; j < rowValues.length; j++) { + // Unescape double quotes + const rowValue = rowValues[j]; + const [td, escapedRowValue] = createTableCell(rowValue); + tr.appendChild(td); + // If the column is a related field, add the id to the form data + if (relatedFieldNames.includes(columnHeaders[j])) { + // If the value of the field is blank, set it to null, but if the + // value is not blank but does not map to a valid id, flag it. + if (escapedRowValue === '') { + newChantObj[columnHeaders[j]] = null; + } else if (relatedFieldMaps[`${columnHeaders[j]}s`].hasOwnProperty(escapedRowValue)) { + newChantObj[columnHeaders[j]] = relatedFieldMaps[`${columnHeaders[j]}s`][escapedRowValue]; + } else { + styleTableError(td, `Invalid value for ${columnHeaders[j]}`); + addGeneralErrorAlert(`Found: invalid value for ${columnHeaders[j]}. See red cells for details.`); + } + } else { + newChantObj[columnHeaders[j]] = escapedRowValue; + } + } + tableBody.appendChild(tr); + newChantsJSON.push(newChantObj); + document.getElementById("csvPreviewDiv").hidden = false; + formData.set('new_chants', JSON.stringify(newChantsJSON)); + } +}; + +function addLoadingSpinner(button) { + const spinnerElem = document.createElement('div'); + spinnerElem.classList.add('spinner-border', 'spinner-border-sm'); + spinnerElem.setAttribute('role', 'status'); + const spinnerSpan = document.createElement('span'); + spinnerSpan.classList.add('visually-hidden'); + spinnerSpan.textContent = 'Loading...'; + spinnerElem.appendChild(spinnerSpan); + button.appendChild(spinnerElem); +}; + +function removeLoadingSpinner(button) { + button.removeChild(button.lastChild); +}; + +function addGeneralErrorAlert(error_message) { + // Add a general error alert to the form + const formErrorDiv = document.getElementById("formErrorAlertDiv"); + const alert = document.createElement('div'); + alert.classList.add('alert', 'alert-danger', 'alert-dismissible'); + formErrorDiv.appendChild(alert); + alert.setAttribute('role', 'alert'); + alert.textContent = error_message; + const closeButton = document.createElement('button'); + closeButton.classList.add('btn-close'); + closeButton.setAttribute('data-bs-dismiss', 'alert'); + closeButton.setAttribute('aria-label', 'Close'); + closeButton.setAttribute('type', 'button'); + alert.appendChild(closeButton); + new bootstrap.Alert(alert); +}; + +document.addEventListener('DOMContentLoaded', function () { + var addChantsForm; + // Add listener to the file input field to parse and display the CSV file + document.getElementById('addChantsCSV').addEventListener('change', function (e) { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = function (e) { + const csv = e.target.result; + addChantsForm = csvLoadCallback(csv, relatedFieldMaps); + }; + reader.readAsText(file); + }); + // Add a listener to handle the form submission + document.getElementById('addChantsForm').addEventListener('submit', function (e) { + e.preventDefault(); + // Add loading spinner to the submit button + const submitButton = document.getElementById('addChantsFormSubmitBtn'); + addLoadingSpinner(submitButton); + // Post the form data to the server + fetch(this.action, { + method: 'POST', + body: addChantsForm + }) + .then(response => { + // Remove the loading spinner from the submit button + removeLoadingSpinner(submitButton); + // If the response is not ok, display the errors. + if (!response.ok) { + response.json().then(data => { + if (data['form_error']) { + // If there is a form error, it is a general error + // that we'll show as an alert. + addGeneralErrorAlert(data['form_error']); + }; + if (data['formset_errors']) { + // If there are errors, they are chant-specific errors + // that we'll display in the preview table. + const columns = document.getElementById('csvPreviewHead').children; + const tableBody = document.getElementById('csvPreviewBody'); + const rows = tableBody.children; + for (error of data['formset_errors']) { + const error_row = rows[error['form_idx']]; + if (error["field_name"] === "__all__") { + styleTableError(error_row, error["error"]); + } else { + const cellIndex = Array.from(columns).findIndex(cell => cell.textContent === error["field_name"]); + const errorCell = error_row.children[cellIndex]; + styleTableError(errorCell, error["error"]); + }; + }; + addGeneralErrorAlert("Errors were found in individual chants. Hover over red cells to see the errors."); + } + } + ); + } else { + // If the response is ok, it is a redirect to the + // browse chants page. Redirect the user to that page. + window.location.href = response.url; + } + }); + + }); + var relatedFieldMaps = {}; + // Get the related field maps + const relatedFields = ['genres', 'services', 'feasts']; + relatedFields.forEach(async (relatedField) => { + relatedFieldMaps[relatedField] = await getRelatedFieldMap(relatedField); + }); +} +); \ No newline at end of file